From 688af60cbfa4289fb3e13b0284622d34dbf77366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:12:52 +0200 Subject: [PATCH 001/759] [noise] Bump noise-c to 0.1.24 and libsodium to 1.10021.6 (#18989) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index a1d9444fc00..4de706120e1 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.21") + cg.add_library("esphome/noise-c", "0.1.24") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.4") + cg.add_library("esphome/libsodium", "1.10021.6") # 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 fcf7caa7c7b..779a05e7de4 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.21 ; noise (api, ota) + esphome/noise-c@0.1.24 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.21 ; noise (api, ota) + esphome/noise-c@0.1.24 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.21 ; used by noise (api, ota) + esphome/noise-c@0.1.24 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index a263d7937fb..4f7f5a4a4c8 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" + " esphome/noise-c @ 0.1.24\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" + " esphome/noise-c @ 0.1.24\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.21"] + assert libs == ["esphome/noise-c @ 0.1.24"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.21", + "esphome/noise-c @ 0.1.24", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.21", - "esphome/noise-c @ 0.1.21", + "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.24", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.21"] + assert cls.calls == ["esphome/noise-c @ 0.1.24"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.21"] is None + assert compats["esphome/noise-c @ 0.1.24"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 379ef52ebd7..fb79885736d 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1576,7 +1576,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1596,7 +1596,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 20c7dcb1ddf6a70aaf75ff418499835c3e99228e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:26:44 +0200 Subject: [PATCH 002/759] [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 (#18990) --- esphome/components/mdns/__init__.py | 2 + esphome/components/mdns/mdns_esp8266.cpp | 51 +++++++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f039bb69f03..c8020104b37 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -192,6 +192,8 @@ async def to_code(config: ConfigType) -> None: if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) + # No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder + cg.add_build_flag("-DNO_GLOBAL_MDNS") elif CORE.is_rp2: cg.add_library("LEAmDNS", None) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 1f0b3c9519d..0e600d3bac1 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -13,8 +13,47 @@ namespace esphome::mdns { +// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and +// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a +// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides +// free the same tx pbufs (#18760). Received packets stay queued during such a call and are +// processed from the main loop afterwards. +class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder { + public: + void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); } + void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); } + + private: + void run_guarded_(bool (GuardedMDNSResponder::*fn)()) { + UdpContext *ctx = this->m_pUDPContext; + if (ctx == nullptr) { + (this->*fn)(); + return; + } + // Set every time: a restart replaces the context together with its stock handler. Only + // begin() and the scheduled netif callback restart, never update() or close(), so the + // context cannot change underneath this call. + ctx->onRx([this]() { + if (!this->in_loop_call_) { + this->_callProcess(); + } + }); + this->in_loop_call_ = true; + (this->*fn)(); + // close() releases the context; a yield in here queues further packets for this loop too + while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) { + this->_parseMessage(); + } + this->in_loop_call_ = false; + } + + volatile bool in_loop_call_{false}; +}; + +static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static void register_esp8266(MDNSComponent *, StaticVector &services) { - MDNS.begin(App.get_name().c_str()); + mdns_responder.begin(App.get_name().c_str()); for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is @@ -30,10 +69,10 @@ static void register_esp8266(MDNSComponent *, StaticVectoris_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) return; #endif - MDNS.update(); + mdns_responder.update_guarded(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } @@ -81,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: #endif void MDNSComponent::on_shutdown() { - MDNS.close(); + mdns_responder.close_guarded(); delay(10); } From 8966567be072926e211b1ca717b7b1d628be7b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:28:31 +0200 Subject: [PATCH 003/759] [core] Show the other downloader's progress while a prefetch job waits on its lock (#18983) --- esphome/framework_helpers.py | 61 +++++++++++++- esphome/platformio/prefetch.py | 87 ++++++++++---------- esphome/platformio/registry.py | 67 ++++++++++----- tests/unit_tests/conftest.py | 39 ++++++++- tests/unit_tests/test_framework_helpers.py | 17 ++++ tests/unit_tests/test_platformio_prefetch.py | 87 ++++++++++++++++++-- tests/unit_tests/test_platformio_registry.py | 73 ++++++++++++++-- 7 files changed, 348 insertions(+), 83 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 82bc0d37272..fc2a18a6ecd 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -23,6 +23,7 @@ from esphome.net_retry import ( ) if TYPE_CHECKING: + from filelock import FileLock import requests PathType = str | os.PathLike @@ -909,6 +910,61 @@ def _part_path(dest: Path) -> Path: return dest.with_name(dest.name + ".part") +def downloaded_bytes(dest: Path, size: int | None = None) -> int: + """Bytes of ``dest`` on disk (its ``.part`` while streaming), capped at ``size``.""" + done = 0 + for candidate in (_part_path(dest), dest): + try: + done = candidate.stat().st_size + break + except FileNotFoundError: + continue + return done if size is None else min(done, size) + + +# Short lock-acquire slices so a waiting worker still observes Ctrl-C +_DOWNLOAD_LOCK_POLL = 1 + +# Waiting on another process's download; past this the caller leaves the +# file to its holder (the later sequential install waits on the same lock) +DOWNLOAD_LOCK_TIMEOUT = 60 + + +class DownloadLockUnavailable(OSError): + """The lock file cannot be used at all (a lock-less filesystem).""" + + +def wait_for_download_lock( + lock: "FileLock", + tracker: Callable[[int], None], + on_disk: Callable[[], int], + name: str, +) -> None: + """Acquire ``lock``, reporting ``on_disk()`` to ``tracker`` each poll so the + bar follows the holder's download. Raises filelock's ``Timeout`` once + ``DOWNLOAD_LOCK_TIMEOUT`` seconds pass.""" + from filelock import Timeout + + deadline = time.monotonic() + DOWNLOAD_LOCK_TIMEOUT + waiting = False + while True: + try: + lock.acquire(timeout=_DOWNLOAD_LOCK_POLL) + return + except Timeout: + pass + except OSError as err: + # Distinct from an OSError out of on_disk(), which must not + # read as "locks unsupported" + raise DownloadLockUnavailable(*err.args) from err + if not waiting: + waiting = True + _LOGGER.info("Waiting for another process downloading %s", name) + tracker(on_disk()) # raises when the batch is cancelled + if time.monotonic() >= deadline: + raise Timeout(lock.lock_file) + + def discard_partial_download(dest: Path) -> None: """Remove ``dest`` and the resume sidecars of an abandoned download.""" part = _part_path(dest) @@ -1319,10 +1375,7 @@ def download_from_mirrors( ) # Tick with the bytes already on disk so a combined bar holds # steady during the backoff instead of rewinding to zero - done = 0 - if progress is not None: - part = _part_path(path_target) - done = part.stat().st_size if part.is_file() else 0 + done = downloaded_bytes(path_target) if progress is not None else 0 _cancellable_sleep(delay, progress, done) # 3. Report every attempted URL if all mirrors failed. failures spans diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 5097239065f..17a06cb9c10 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -33,11 +33,14 @@ import time from typing import Any, NamedTuple from esphome.framework_helpers import ( + DownloadLockUnavailable, content_length, discard_partial_download, + downloaded_bytes, failure_reason, resume_fetch_job, run_batch_downloads, + wait_for_download_lock, warn_prefetch_failures, ) from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree @@ -61,16 +64,10 @@ _RESOLVE_WORKERS = 8 # A hung child must not block the build; downloads resume on the next run _PREFETCH_TIMEOUT = 20 * 60 -# Waiting on another process's URL download; past this, leave it to pio -_DOWNLOAD_LOCK_TIMEOUT = 60 - # Child exit for a handled, already-warned failure; 1 would collide with # the interpreter's own import-failure exit _EXIT_HANDLED = 3 -# Short lock-acquire slices so a waiting worker still observes Ctrl-C -_URI_LOCK_POLL = 1 - # Resolution errored (vs a clean skip); suppresses the warm sentinel _RESOLVE_FAILED = object() @@ -462,51 +459,54 @@ def _uri_jobs( def _serialized_fetch_job( - dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True + dl_path: Path, + lock_path: str, + body: Any, + size: int, + stream_dest: Path | None = None, + unlocked_ok: bool = True, ) -> Any: - """Wrap ``body`` so the shared destination is single-writer. - - Interleaved writers truncate each other's ``.part`` bytes (see - registry.py). The bounded poll observes Ctrl-C via the tracker; a - blown deadline is a clean skip (the holder's copy is what the build - needs). On a lock-less filesystem a sha256-verified body runs - unlocked with one warning; a checksum-less one - (``unlocked_ok=False``) is a counted failure instead. + """Wrap ``body`` so the shared destination is single-writer (interleaved + writers truncate each other's ``.part``, see registry.py). A blown deadline + is a clean skip. On a lock-less filesystem a sha256-verified body runs + unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails. """ + def on_disk() -> int: + # A URL job's holder streams beside the staging path until it + # promotes; after that only dl_path is left + done = downloaded_bytes(dl_path, size) + if not done and stream_dest is not None: + done = downloaded_bytes(stream_dest, size) + return done + def run(tracker: Any) -> None: from filelock import FileLock, Timeout # fallback_to_soft would leave a stale marker on lock-less # filesystems that blocks every later build (see git.py) lock = FileLock(lock_path, fallback_to_soft=False) - deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT - while True: - try: - lock.acquire(timeout=_URI_LOCK_POLL) - break - except Timeout: - tracker(0) # raises when the batch is cancelled - if time.monotonic() >= deadline: - # Another process is fetching this same file; its copy - # is what the build needs (a large framework archive - # can hold the lock far longer than this deadline) - _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) - return - except OSError as err: - if not unlocked_ok: - # A body with no checksum to catch interleaved corruption - raise - lock = None - _LOGGER.warning( - "Could not lock %s (%s); downloading unlocked", - dl_path.name, - err, - ) - break + try: + wait_for_download_lock(lock, tracker, on_disk, dl_path.name) + except Timeout: + # The holder's copy is what the build needs (a large + # framework archive can outlast this deadline) + _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) + return + except DownloadLockUnavailable as err: + if not unlocked_ok: + # A body with no checksum to catch interleaved corruption + raise + lock = None + _LOGGER.warning( + "Could not lock %s (%s); downloading unlocked", + dl_path.name, + err, + ) try: if dl_path.is_file(): - return # another process finished it while we waited + tracker(size) # another process finished it while we waited + return body(tracker) finally: if lock is not None: @@ -540,6 +540,7 @@ def _registry_fetch_job( dl_path, f"{dl_path}.esphome.lock", resume_fetch_job(url, dl_path, sha256=checksum, size=size), + size, ) def run(tracker: Any) -> None: @@ -571,9 +572,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any: tmp.replace(dl_path) def run(tracker: Any) -> None: - _serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)( - tracker - ) + _serialized_fetch_job( + dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False + )(tracker) if dl_path.is_file(): # Won or lost, the race is over; staging files left behind # are dead weight PlatformIO's cache never prunes diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index 9538a28ff4d..75df82da0e9 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -17,8 +17,10 @@ from esphome.framework_helpers import ( archive_extract_all, download_from_mirrors, download_with_resume, + downloaded_bytes, rmdir, run_batch_downloads, + wait_for_download_lock, ) from esphome.net_retry import fetch_with_retry, http_request @@ -164,11 +166,17 @@ class _PendingArchive(NamedTuple): name: str version: str dest: Path + archive: Path url: str sha256: str size: int +def _archive_path(downloads_dir: Path, name: str, version: str) -> Path: + """The one archive path the prefetch and the sequential install share.""" + return downloads_dir / f"{name}-{version}" + + def _already_installed(dest: Path) -> bool: """Whether ``dest`` holds a completed install (extraction marker).""" return (dest / ".esphome_extracted").is_file() @@ -187,18 +195,18 @@ def prefetch_packages( lock as ``install_package``: the archive's ``.part`` file is shared, and two concurrent writers would truncate each other's bytes. """ - from filelock import FileLock + from filelock import FileLock, Timeout pending: list[_PendingArchive] = [] - seen: set[str] = set() + seen: set[Path] = set() for name, version, dest, mirrors in packages: if mirrors or (dest / ".esphome_extracted").is_file(): continue - archive_name = f"{name}-{version}" - if archive_name in seen: + archive = _archive_path(downloads_dir, name, version) + if archive in seen: # A duplicate entry would race itself between two workers continue - seen.add(archive_name) + seen.add(archive) try: url, sha256, size = registry_download(name, version) except EsphomeError as err: @@ -207,10 +215,9 @@ def prefetch_packages( continue if not size: continue - archive = downloads_dir / archive_name if archive.is_file() and archive.stat().st_size == size: continue - pending.append(_PendingArchive(name, version, dest, url, sha256, size)) + pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size)) if len(pending) < 2: return downloads_dir.mkdir(parents=True, exist_ok=True) @@ -222,20 +229,36 @@ def prefetch_packages( def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None: entry.dest.parent.mkdir(parents=True, exist_ok=True) - with FileLock(f"{entry.dest}.lock", fallback_to_soft=False): - # Marker re-check: a concurrent build may have installed (and - # deleted the archive of) this package while we waited; - # re-downloading would orphan a fresh copy in downloads_dir - # no branch: the thread tracer misses the skip edge; both - # arms of _already_installed are pinned directly - if not _already_installed(entry.dest): # pragma: no branch - download_with_resume( - entry.url, - downloads_dir / f"{entry.name}-{entry.version}", - sha256=entry.sha256, - size=entry.size, - progress=tracker, - ) + + def on_disk() -> int: + if done := downloaded_bytes(entry.archive, entry.size): + return done + # The holder deletes the archive once it has installed it + return entry.size if _already_installed(entry.dest) else 0 + + lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False) + try: + wait_for_download_lock(lock, tracker, on_disk, entry.name) + except Timeout: + # install_package waits on this same lock and verifies the + # holder's copy + _LOGGER.debug("Leaving %s to its current downloader", entry.name) + return + try: + if _already_installed(entry.dest): + # A concurrent build installed it while we waited; a + # re-download would orphan a fresh copy in downloads_dir + tracker(entry.size) + return + download_with_resume( + entry.url, + entry.archive, + sha256=entry.sha256, + size=entry.size, + progress=tracker, + ) + finally: + lock.release() failures = run_batch_downloads( "Downloading packages", @@ -288,7 +311,7 @@ def install_package( rmdir(dest, msg=f"Clean up incomplete {name} install") # Persistent location so an interrupted download resumes across runs. downloads_dir.mkdir(parents=True, exist_ok=True) - archive = downloads_dir / f"{name}-{version}" + archive = _archive_path(downloads_dir, name, version) _LOGGER.info("Downloading %s %s ...", name, version) if mirrors: _LOGGER.warning( diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 9de8f715efc..ad9c0bb11fc 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -9,7 +9,7 @@ not be part of a unit test suite. """ -from collections.abc import Generator +from collections.abc import Callable, Generator import os from pathlib import Path import sys @@ -137,3 +137,40 @@ def mock_get_component() -> Generator[Mock, None, None]: """Mock get_component for config module.""" with patch("esphome.config.get_component") as mock: yield mock + + +@pytest.fixture +def held_lock() -> Callable[..., Callable[..., None]]: + """Factory for a ``FileLock.acquire`` fake held by another downloader. + + Each poll writes the next chunk to ``part`` (or runs it, for a callable) + and raises ``Timeout``; when the chunks run out the part is removed, + ``land()`` runs, and the acquire succeeds (also for any later job, so + ``land`` must be idempotent). + """ + from filelock import Timeout + + def make( + part: Path, + chunks: list[bytes | Callable[[], None]], + land: Callable[[], None], + ) -> Callable[..., None]: + polls = iter(chunks) + + def acquire(*args, **kwargs) -> None: + try: + chunk = next(polls) + except StopIteration: + part.unlink(missing_ok=True) + land() + return + if callable(chunk): + chunk() + else: + part.parent.mkdir(parents=True, exist_ok=True) + part.write_bytes(chunk) + raise Timeout("held") + + return acquire + + return make diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fcc5572f511..22b34c9df51 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -2353,3 +2353,20 @@ def test_discard_partial_download_logs_undeletable( ): framework_helpers.discard_partial_download(dest) assert "Could not remove" in caplog.text + + +def test_downloaded_bytes_reports_what_is_on_disk(tmp_path: Path) -> None: + """Part file first, then the landed file, both capped at size; else 0.""" + dest = tmp_path / "archive" + assert framework_helpers.downloaded_bytes(dest, 4) == 0 + part = tmp_path / "archive.part" + part.write_bytes(b"ab") + assert framework_helpers.downloaded_bytes(dest, 4) == 2 + part.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 + part.unlink() + dest.write_bytes(b"abc") + assert framework_helpers.downloaded_bytes(dest, 4) == 3 + assert framework_helpers.downloaded_bytes(dest) == 3 + dest.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index fb79885736d..77490fd8613 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -454,23 +454,96 @@ def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None: assert dl_path.read_bytes() == b"data" -def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None: - """A lock held past the deadline means another process is fetching the - same file; skipping cleanly beats a misleading failure warning. The - tracker is still polled so a parked worker observes cancellation.""" +@pytest.mark.parametrize("staged", [b"", b"ab"]) +def test_lock_deadline_leaves_download_to_the_holder( + tmp_path: Path, staged: bytes +) -> None: + """A lock held past the deadline is another process's download; skip + cleanly, polling the tracker with what the holder has staged so far.""" dl_path = tmp_path / "archive" + (tmp_path / "archive.prefetch.part").write_bytes(staged) ticks: list[int] = [] with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) mock_download.assert_not_called() - assert ticks == [0] + assert ticks == [len(staged)] assert not dl_path.exists() +@pytest.mark.parametrize( + ("job", "part_name", "chunks", "expected"), + [ + ( + lambda dl_path: pf._registry_fetch_job( + MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4 + ), + "archive.part", + [b"a", b"abc"], + [1, 3, 4], + ), + ( + lambda dl_path: pf._uri_fetch_job( + MagicMock(), "https://x/a.zip", dl_path, 4 + ), + "archive.prefetch.part", + [b"ab"], + [2, 4], + ), + ], + ids=["registry", "uri"], +) +def test_lock_wait_reports_the_holders_progress( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + held_lock, + job, + part_name: str, + chunks: list[bytes], + expected: list[int], +) -> None: + """A waiting job reports the holder's part file (the staging one for a + URL job), then the full size once the holder lands the archive.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / part_name, chunks, lambda: dl_path.write_bytes(b"abcd") + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + caplog.at_level(logging.INFO), + ): + job(dl_path)(ticks.append) + mock_download.assert_not_called() + assert ticks == expected + assert caplog.text.count("Waiting for another process downloading archive") == 1 + + +def test_uri_lock_wait_prefers_the_landed_archive(tmp_path: Path, held_lock) -> None: + """Between the holder's promotion rename and its release the staging + part is gone; the landed cache file is credited instead of 0.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / "archive.prefetch.part", + [b"ab", lambda: dl_path.write_bytes(b"abcd")], + lambda: None, + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + ): + pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) + mock_download.assert_not_called() + assert ticks == [2, 4, 4] + + def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: """A registry job that lost the download race to another process must not stamp a nonexistent archive into pio's usage.db.""" @@ -479,7 +552,7 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)( lambda done: None diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 6ba8691c4ec..9d5f6c4ce59 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -8,6 +8,7 @@ import os from pathlib import Path from unittest.mock import MagicMock, patch +from filelock import Timeout import pytest from esphome.core import EsphomeError @@ -540,16 +541,13 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: dest = tmp_path / "a" dest.mkdir() - from contextlib import contextmanager - - @contextmanager - def marker_appears_under_lock(path, **kwargs): + def marker_appears_under_lock(*args, **kwargs): # Simulates the concurrent build finishing while we waited (dest / ".esphome_extracted").touch() - yield with ( - patch("filelock.FileLock", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.acquire", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.release"), patch.object(registry, "download_with_resume") as mock_download, patch.object( registry, "registry_download", side_effect=_resolve_for({"a": 10}) @@ -559,6 +557,69 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: mock_download.assert_not_called() +def test_prefetch_packages_waits_with_the_holders_progress( + tmp_path: Path, held_lock +) -> None: + """A worker parked on another build's lock reports that build's part + file, then the full size once the marker appears.""" + dest = tmp_path / "a" + dest.mkdir() + ticks: list[int] = [] + part = tmp_path / "dl" / "a-1.0.part" + + def installed_and_pruned() -> None: + # install_package touches the marker, then unlinks the archive + (dest / ".esphome_extracted").touch() + part.unlink() + + acquire = held_lock( + part, + [lambda: None, b"abc", installed_and_pruned], + (dest / ".esphome_extracted").touch, + ) + + def fake_batch(header, jobs): + for _name, _size, fetch in jobs: + fetch(ticks.append) + return [] + + with ( + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + patch.object(registry, "run_batch_downloads", side_effect=fake_batch), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])], + tmp_path / "dl", + ) + assert ticks == [0, 3, 10, 10] + mock_download.assert_called_once() + + +def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder( + tmp_path: Path, +) -> None: + """Past the deadline the worker skips; install_package waits on the same + lock later and verifies whatever the holder produced.""" + with ( + patch("filelock.FileLock.acquire", side_effect=Timeout("held")), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + def test_already_installed_probe(tmp_path: Path) -> None: """Both arms of the marker probe the prefetch worker keys on.""" dest = tmp_path / "pkg" From d58b37faa1eff3324dd6c9389c864d5c3576eadf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:29:20 +1200 Subject: [PATCH 004/759] [esp32_hosted] Add ESP-NOW-over-hosted shim for the ESP32-P4 (#17712) --- esphome/components/esp32_hosted/__init__.py | 36 ++ .../esp32_hosted/esp_now_hosted.cpp | 467 ++++++++++++++++++ .../esp32_hosted/esp_now_hosted_rpc.h | 128 +++++ esphome/components/espnow/__init__.py | 20 + esphome/core/defines.h | 1 + script/ci-custom.py | 17 +- .../test-espnow.esp32-p4-idf.yaml | 5 + tests/unit_tests/components/test_espnow.py | 48 ++ 8 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 esphome/components/esp32_hosted/esp_now_hosted.cpp create mode 100644 esphome/components/esp32_hosted/esp_now_hosted_rpc.h create mode 100644 tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml create mode 100644 tests/unit_tests/components/test_espnow.py diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index ab9455250cd..21626e432b6 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -37,6 +37,25 @@ CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" CONF_SPI_MODE = "spi_mode" +# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h +# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects +# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the +# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES +# those symbols and forwards each call to the co-processor over esp-hosted's +# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links +# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to +# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native +# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host +# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ); +# we ask for 8 to leave room for other CustomRpc extensions alongside. +# +# The coprocessor must run the matching custom firmware (a parallel effort in +# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy +# of the wire contract and MUST stay byte-identical to the copy that coprocessor +# firmware uses — the packed structs are the on-wire layout, so any divergence +# silently corrupts every ESP-NOW frame. +_MAX_CUSTOM_MSG_HANDLERS = 8 + # Shared fields for both transport modes BASE_SCHEMA = cv.Schema( { @@ -262,6 +281,23 @@ async def to_code(config: ConfigType) -> None: else: _configure_spi(config) + # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see + # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not + # gated on the `espnow` component being present: the shim is tiny and the + # esp_now_* symbols/CustomRpc calls it defines require these Kconfig options + # to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so + # coupling the two keeps the build consistent. When `espnow` is absent the + # symbols are simply unused and never register a callback at runtime. + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + add_define("USE_ESP_NOW_HOSTED") + # esp-hosted's CustomRpc ("peer data transfer") path — off by default. + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS + ) + # Place the transport mempool in PSRAM. Required on memory-tight host # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM # mempool allocation fails at boot with `sdio_mempool_create` assert. diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp new file mode 100644 index 00000000000..ad29b208feb --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -0,0 +1,467 @@ +/* + * esp_now_hosted — host-side shim implementing over esp-hosted + * CustomRpc, so ESPHome's `espnow` component can run on a radio-less host + * (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor. + * + * A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full + * esp_now.h header (types + declarations) but ships NO implementation, so every + * esp_now_* symbol is an undefined reference at link time. This translation + * unit provides those definitions; each forwards to the co-processor over + * CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor + * handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no + * duplicate-symbol clash because nothing else defines these symbols here. + * + * See esp_now_hosted_rpc.h for the wire protocol. + */ + +#include "sdkconfig.h" + +// Only build the shim on the radio-less host. On chips with a native ESP-NOW +// stack (S3, C6, …) the real symbols exist and this file must stay empty to +// avoid duplicate definitions. +#if defined(CONFIG_IDF_TARGET_ESP32P4) + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_idf_version.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include // injected declarations we are now DEFINING +#include // wifi_pkt_rx_ctrl_t, wifi_tx_info_t + +// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it +// from C++ would give its declarations C++ linkage and the real C symbols in +// libesp_hosted would go unresolved at link. Wrap it. (Verified vs +// esp_hosted 2.12.9.) +extern "C" { +#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback} +} + +#include "esp_now_hosted_rpc.h" + +namespace { + +const char *const TAG = "esp_now_hosted"; + +// One outstanding request at a time. ESPHome drives esp_now_* from the main +// loop; the matching response and the async RECV/SEND events all arrive on the +// single esp-hosted RPC RX thread. Serializing requests keeps the shared +// response slot race-free; a sequence number stops a late/stale response from +// being mistaken for ours. +SemaphoreHandle_t g_req_mutex = nullptr; +SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands +bool g_setup_done = false; // set only after setup fully succeeds +uint8_t g_seq = 0; +volatile uint8_t g_expect_seq = 0; +volatile int32_t g_resp_status = 0; +uint8_t g_resp_ret[16]; +volatile uint16_t g_resp_ret_len = 0; + +// Written from the main loop (register/unregister/deinit), read from the +// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the +// g_resp_* globals are: force the RX thread to observe an updated pointer +// (e.g. a nulling by esp_now_deinit) rather than a cached one. +volatile esp_now_recv_cb_t g_recv_cb = nullptr; +volatile esp_now_send_cb_t g_send_cb = nullptr; + +// Local mirror of the co-processor's peer table. ESPHome's espnow component +// calls esp_now_is_peer_exist() on the main loop for every received frame +// (twice) and every send; forwarding each as a blocking RPC round-trip stalls +// the loop. The shim is the only path that mutates the co-processor peer table +// (add/del/deinit all go through here), so this mirror is authoritative and +// esp_now_is_peer_exist() can answer from it with no round-trip. +// +// esp_now_* are public C symbols: any component or user lambda may call them, +// and although ESPHome's espnow touches peers only from the main loop today +// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short +// spinlock keeps the mirror consistent from any task/core, matching native +// esp_now_*'s own internal thread-safety. The critical sections are a bounded +// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20. +constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; +uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; +size_t g_peer_count = 0; +portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; + +// Caller must hold g_peer_lock. +int peer_cache_find_locked(const uint8_t *mac) { + for (size_t i = 0; i < g_peer_count; i++) { + if (memcmp(g_peer_cache[i], mac, 6) == 0) + return static_cast(i); + } + return -1; +} + +bool peer_cache_contains(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const bool found = peer_cache_find_locked(mac) >= 0; + portEXIT_CRITICAL(&g_peer_lock); + return found; +} + +void peer_cache_add(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS) + memcpy(g_peer_cache[g_peer_count++], mac, 6); + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_remove(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const int idx = peer_cache_find_locked(mac); + if (idx >= 0) { + g_peer_count--; + if (static_cast(idx) != g_peer_count) // move the last entry into the gap + memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6); + } + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_clear() { + portENTER_CRITICAL(&g_peer_lock); + g_peer_count = 0; + portEXIT_CRITICAL(&g_peer_lock); +} + +// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ────────── +// Keep them short and non-blocking. In particular they MUST NOT call back into +// any esp_now_* shim function: that would try to take g_req_mutex / wait on the +// RX thread that delivers the response, and deadlock. + +void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + if (len < sizeof(esp_now_hosted_resp_t)) { + ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast(len)); + return; + } + const auto *r = reinterpret_cast(data); + if (r->seq != g_expect_seq) { // late response from a timed-out request (expected) + ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq); + return; + } + g_resp_status = r->status; + uint16_t rl = r->ret_len; + if (rl > sizeof(g_resp_ret)) { + // Larger than any real opcode return — a likely wire-format drift signal. + ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl); + rl = sizeof(g_resp_ret); + } + if (len >= sizeof(esp_now_hosted_resp_t) + rl) { + memcpy(g_resp_ret, r->ret, rl); + } else { + // Truncated frame: fail closed. Never hand the caller stale bytes left in + // g_resp_ret by a previous response, and don't let request() report a + // zeroed payload as success — override the status to an error. + ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl); + rl = 0; + g_resp_status = ESP_ERR_INVALID_RESPONSE; + } + g_resp_ret_len = rl; + xSemaphoreGive(g_resp_sem); +} + +void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via + // the espnow component's disable()) can null it on the main loop between the + // guard and the call, which would otherwise turn the call into a null-deref. + const esp_now_recv_cb_t cb = g_recv_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_recv_evt_t)) { + ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); + if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) { + ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len); + return; + } + + // ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one. + wifi_pkt_rx_ctrl_t rx_ctrl; + memset(&rx_ctrl, 0, sizeof(rx_ctrl)); + rx_ctrl.rssi = e->rssi; + rx_ctrl.channel = e->channel; + rx_ctrl.timestamp = static_cast(esp_timer_get_time()); + + esp_now_recv_info_t info; + info.src_addr = const_cast(e->src_addr); + info.des_addr = const_cast(e->des_addr); + info.rx_ctrl = &rx_ctrl; + cb(&info, e->data, static_cast(e->data_len)); +} + +void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once (see on_recv): disable()/deinit() can null it + // on the main loop concurrently with this RX-thread callback. + const esp_now_send_cb_t cb = g_send_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_send_evt_t)) { + ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t), + // whose des_addr is a POINTER (not an inline array). Point it at the event's + // MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and + // faults). ESPHome reads only info->des_addr. + esp_now_send_info_t si; + memset(&si, 0, sizeof(si)); + si.des_addr = const_cast(e->des_addr); + cb(&si, static_cast(e->status)); +#else + cb(e->des_addr, static_cast(e->status)); +#endif +} + +esp_err_t ensure_setup() { + // Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a + // semaphore that did not allocate, a callback that did not register) must not + // leave a later call thinking setup completed. Semaphore creation is guarded + // so a retry after a partial failure does not leak the earlier handles. + if (g_setup_done) + return ESP_OK; + if (g_req_mutex == nullptr) + g_req_mutex = xSemaphoreCreateMutex(); + if (g_resp_sem == nullptr) + g_resp_sem = xSemaphoreCreateBinary(); + if (g_req_mutex == nullptr || g_resp_sem == nullptr) + return ESP_ERR_NO_MEM; + esp_err_t err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + return err; + g_setup_done = true; + return ESP_OK; +} + +// Send one request envelope. With wait=true (default) block until the matching +// response (or timeout); with wait=false return as soon as the frame is handed +// to the transport (fire-and-forget, used by esp_now_send). +// +// `tail` is an optional second chunk written straight after `payload`. Callers +// with a fixed header plus a bulk body (esp_now_send) pass the two separately +// so they never need a build buffer of their own: both chunks are laid into the +// request buffer here, under g_req_mutex, which keeps concurrent callers from +// racing and saves a full copy of the body on every transmit. +esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len, + bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen) + return ESP_ERR_INVALID_SIZE; + const uint16_t total_len = static_cast(plen + tail_len); + + if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE) + return ESP_FAIL; + + static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex + auto *req = reinterpret_cast(buf); + req->opcode = opcode; + req->seq = ++g_seq; + req->payload_len = total_len; + if (plen != 0) + memcpy(req->payload, payload, plen); + if (tail_len != 0) + memcpy(req->payload + plen, tail, tail_len); + g_expect_seq = req->seq; + + xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending + err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len); + if (err != ESP_OK) { + xSemaphoreGive(g_req_mutex); + return err; + } + if (!wait) { + // Fire-and-forget (esp_now_send): the co-processor enqueues the frame and + // reports the real TX result later via the async SEND event, exactly like + // native esp_now_send. Returning here keeps the main loop off the ~100 ms+ + // RPC round-trip. The matching RESP is ignored (seq won't match the next + // waited request, so on_resp drops it). + xSemaphoreGive(g_req_mutex); + return ESP_OK; + } + if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "opcode %u timed out", opcode); + xSemaphoreGive(g_req_mutex); + return ESP_ERR_TIMEOUT; + } + + const int32_t status = g_resp_status; + if (ret != nullptr && ret_cap != 0) { + uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap; + memcpy(ret, const_cast(g_resp_ret), n); + if (ret_len != nullptr) + *ret_len = n; + } + xSemaphoreGive(g_req_mutex); + return static_cast(status); +} + +} // namespace + +// ── The surface, defined for the radio-less host ──────────────── +extern "C" { + +esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); } + +esp_err_t esp_now_deinit(void) { + g_recv_cb = nullptr; + g_send_cb = nullptr; + peer_cache_clear(); // the co-processor drops all peers on deinit + return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr); +} + +esp_err_t esp_now_get_version(uint32_t *version) { + uint32_t v = 0; + uint16_t rl = 0; + esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl); + if (version != nullptr) + *version = v; + return err; +} + +esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) { + // Only arm the callback once the CustomRpc handlers are actually registered, + // so a failed setup leaves g_recv_cb null rather than falsely "registered". + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_recv_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_recv_cb(void) { + g_recv_cb = nullptr; + return ESP_OK; +} +esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_send_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_send_cb(void) { + g_send_cb = nullptr; + return ESP_OK; +} + +static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) { + if (peer == nullptr) + return ESP_ERR_ESPNOW_ARG; + esp_now_hosted_peer_t p; + memset(&p, 0, sizeof(p)); + memcpy(p.peer_addr, peer->peer_addr, 6); + memcpy(p.lmk, peer->lmk, 16); + p.channel = peer->channel; + p.ifidx = static_cast(peer->ifidx); + p.encrypt = peer->encrypt ? 1 : 0; + return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait); +} +esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) { + // Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip, + // and ESPHome's espnow calls it on the main loop when a device joins the mesh + // — under co-processor load that stalls the UI (peer-churn stutter). Issue it + // without waiting and mirror it locally. Safe against a following + // esp_now_send to the same peer: both ride the same in-order CustomRpc + // channel (mutex-serialized on the host) and the co-processor processes REQs + // FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side + // failure (e.g. peer table full) is no longer reported synchronously — the + // same limitation as esp_now_send — but ESPHome only adds peers it validated. + esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false); + if (err == ESP_OK) + peer_cache_add(peer->peer_addr); // keep the local mirror in sync + return err; +} +esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) { + // mod_peer changes a peer's parameters, not its existence, so the cache is + // unaffected. Kept synchronous — it is not on any hot path (espnow never + // calls it), so the extra round-trip does not matter and the status is useful. + return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true); +} + +esp_err_t esp_now_del_peer(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return ESP_ERR_ESPNOW_ARG; + // Fire-and-forget for the same reason as add_peer (peer churn on the main + // loop). Removal is order-independent, so this is strictly safe. + esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false); + if (err == ESP_OK) + peer_cache_remove(peer_addr); // keep the local mirror in sync + return err; +} + +bool esp_now_is_peer_exist(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return false; + // Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls + // this on the main loop for every received frame and every send, so a + // blocking round-trip here would stall rendering under mesh traffic. + return peer_cache_contains(peer_addr); +} + +esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) { + if (len > ESP_NOW_HOSTED_MAX_FRAME) + return ESP_ERR_ESPNOW_ARG; + if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error + return ESP_ERR_ESPNOW_ARG; + // Only the small fixed header is built here; the caller's frame goes over as + // the request tail, so request() lays both into its own buffer under + // g_req_mutex. esp_now_send is a public C symbol and may be called from any + // task, and a shared build buffer here would let two callers corrupt each + // other's frame. Passing the body through also drops a full-frame copy per + // transmit, on the path this shim exists to keep quick. + uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; + auto *s = reinterpret_cast(hdr); + s->has_addr = peer_addr != nullptr ? 1 : 0; + if (peer_addr != nullptr) + memcpy(s->peer_addr, peer_addr, 6); + else + memset(s->peer_addr, 0, 6); + s->data_len = static_cast(len); + // Fire-and-forget (wait=false): native esp_now_send returns once the frame is + // queued, with the real TX result delivered later through the send callback. + // The co-processor mirrors that — it acks enqueue immediately and reports the + // outcome via the async SEND event (on_send -> on_send_report). Waiting for + // the RPC RESP here would block the main loop for the full round-trip on + // every transmit. + return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data, + static_cast(len)); +} + +esp_err_t esp_now_set_pmk(const uint8_t *pmk) { + if (pmk == nullptr) + return ESP_ERR_ESPNOW_ARG; + return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr); +} + +// Remainder of the surface. Not used by ESPHome's espnow component +// today; provided so the whole header links and future callers get a defined +// (if unimplemented) symbol rather than a link error. Wire them through +// CustomRpc if a use case appears. +esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_set_wake_window(uint16_t /*window*/) { + return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success +} +esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) { + return ESP_ERR_NOT_SUPPORTED; +} + +} // extern "C" + +#endif // CONFIG_IDF_TARGET_ESP32P4 diff --git a/esphome/components/esp32_hosted/esp_now_hosted_rpc.h b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h new file mode 100644 index 00000000000..bf68c759ee9 --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h @@ -0,0 +1,128 @@ +/* + * esp_now_hosted — ESP-NOW-over-CustomRpc wire protocol. + * + * Shared, byte-for-byte-identical contract between: + * - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp) + * - the coprocessor firmware (esphome/esp-hosted-firmware) + * + * It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer", + * available since esp-hosted v2.8.1), teaching the radio-less host <-> radio + * co-processor link to carry esp_now.h, which esp-hosted itself does not proxy + * (Espressif issue espressif/esp-hosted-mcu#19). + * + * KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor + * firmware uses a verbatim copy. Both sides are little-endian, so these packed + * structs are wire-compatible with no byte-swapping. + */ + +#ifndef ESP_NOW_HOSTED_RPC_H +#define ESP_NOW_HOSTED_RPC_H + +#ifdef __cplusplus +#include +#else +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ────────────────── + * One REQ handler slot on the device; three event handler slots on the host. + * The bytes spell "now" + index, a private range unlikely to clash with other + * CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */ +#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */ +#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */ +#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */ +#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */ + +/* ── Request opcodes ────────────────────────────────────────────────────── */ +enum { + ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */ + ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */ + ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */ + ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */ + ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */ + ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */ + ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */ + ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */ + ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */ +}; + +/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well + * under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */ +#define ESP_NOW_HOSTED_MAX_FRAME 1470u +/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */ +#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u) +/* Host request/response round-trip timeout over the transport. Generous: + * normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor + * can stall the RX thread. */ +#define ESP_NOW_HOSTED_TIMEOUT_MS 2000 + +/* ── Envelopes ──────────────────────────────────────────────────────────── */ + +/* These payloads are shared verbatim with the C co-processor firmware, so they + * use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases, + * which would not compile there. Silence clang-tidy's modernize-use-using for + * the shared struct block. */ +// NOLINTBEGIN(modernize-use-using) +typedef struct { + uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */ + uint8_t seq; /* wraps 0..255; echoed in the response for matching */ + uint16_t payload_len; /* bytes of opcode-specific payload that follow */ + uint8_t payload[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_req_t; + +typedef struct { + uint8_t opcode; /* echoes the request opcode */ + uint8_t seq; /* echoes the request seq */ + int32_t status; /* esp_err_t from the native call on the co-processor */ + uint16_t ret_len; /* bytes of return payload that follow */ + uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */ +} __attribute__((packed)) esp_now_hosted_resp_t; + +/* ── Opcode payloads ────────────────────────────────────────────────────── */ + +/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless + * across the transport and never set by ESPHome's espnow component. */ +typedef struct { + uint8_t peer_addr[6]; + uint8_t lmk[16]; + uint8_t channel; /* 0 = current channel */ + uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */ + uint8_t encrypt; /* bool */ +} __attribute__((packed)) esp_now_hosted_peer_t; + +typedef struct { + uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */ + uint8_t peer_addr[6]; + uint16_t data_len; + uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */ +} __attribute__((packed)) esp_now_hosted_send_req_t; + +/* ── Async events (device -> host) ──────────────────────────────────────── */ + +/* Reconstructed on the host into an esp_now_recv_info_t + a minimal + * wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr, + * info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */ +typedef struct { + uint8_t src_addr[6]; + uint8_t des_addr[6]; + int8_t rssi; + uint8_t channel; + uint16_t data_len; + uint8_t data[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_recv_evt_t; + +typedef struct { + uint8_t des_addr[6]; + uint8_t status; /* esp_now_send_status_t (0 = success) */ +} __attribute__((packed)) esp_now_hosted_send_evt_t; +// NOLINTEND(modernize-use-using) + +#ifdef __cplusplus +} +#endif + +#endif /* ESP_NOW_HOSTED_RPC_H */ diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 5541a6ee97d..14d099ec062 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.udp import CONF_ON_RECEIVE import esphome.config_validation as cv from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -132,6 +134,24 @@ CONFIG_SCHEMA = cv.All( ) +def _validate_variant(config: ConfigType) -> ConfigType: + # ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native + # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that + # supplies the esp_now_* symbols. Fail here with a clear message instead of + # letting the build reach an "undefined reference to esp_now_*" link error. + variant = get_esp32_variant() + if wifi.variant_has_wifi(variant): + return config + if variant != VARIANT_ESP32P4: + raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)") + if "esp32_hosted" not in fv.full_config.get(): + raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component") + return config + + +FINAL_VALIDATE_SCHEMA = _validate_variant + + async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9dd1e0ced64..eaece6d5ffa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP32_IMPROV_STATE_CALLBACK +#define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN #define USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/script/ci-custom.py b/script/ci-custom.py index f481fda8609..e2b7cd8d376 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -294,6 +294,9 @@ def highlight(s): "esphome/components/socket/headers.h", "esphome/core/defines.h", "esphome/components/http_request/httplib.h", + # Shared C wire header (byte-identical with the co-processor firmware); + # these are protocol constants and constexpr is C++-only. + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", ], ) def lint_no_defines(fname, match): @@ -816,6 +819,10 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", "esphome/components/http_request/httplib.h", + # Global extern "C" esp_now_* linker symbols + shared C wire header; + # neither can live in a C++ namespace. + "esphome/components/esp32_hosted/esp_now_hosted.cpp", + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", ], ) def lint_namespace(fname: Path, content: str) -> str | None: @@ -841,7 +848,15 @@ def lint_esphome_h(fname, line, col, content): ) -@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) +@lint_content_check( + include=["*.h"], + exclude=[ + "esphome/core/entity_types.h", + # Shared C wire header; uses a classic #ifndef guard for portability + # across the co-processor firmware repo it stays byte-identical with. + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", + ], +) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( diff --git a/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml new file mode 100644 index 00000000000..fab0a64ab84 --- /dev/null +++ b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the ESP-NOW-over-hosted shim: on the ESP32-P4 host, esp32_hosted +# supplies the esp_now_* symbols that the espnow component links against. +packages: + esp32_hosted: !include common.yaml + espnow: !include ../espnow/common.yaml diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py new file mode 100644 index 00000000000..21305c2b332 --- /dev/null +++ b/tests/unit_tests/components/test_espnow.py @@ -0,0 +1,48 @@ +"""Tests for the espnow component's final validation.""" + +import pytest + +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +) +from esphome.components.espnow import _validate_variant +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _run( + monkeypatch, variant: str, full_config: dict, config: ConfigType +) -> ConfigType: + monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + token = fv.full_config.set(full_config) + try: + return _validate_variant(config) + finally: + fv.full_config.reset(token) + + +def test_variant_with_native_wifi_passes(monkeypatch) -> None: + """A variant with a native Wi-Fi PHY needs no shim; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config + + +def test_radioless_non_p4_variant_rejected(monkeypatch) -> None: + """Radio-less variants without any ESP-NOW path are rejected outright.""" + with pytest.raises(cv.Invalid, match="not supported"): + _run(monkeypatch, VARIANT_ESP32H2, {}, {}) + + +def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None: + """The P4 needs the esp32_hosted shim to supply the esp_now_* symbols.""" + with pytest.raises(cv.Invalid, match="esp32_hosted"): + _run(monkeypatch, VARIANT_ESP32P4, {}, {}) + + +def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: + """The P4 with esp32_hosted present validates; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config From 3321566cc010c3a4e774a78e1d81d887c8e02879 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 3 Sep 2026 07:12:36 -0500 Subject: [PATCH 005/759] [remote_transmitter] Fix BK7231N build by limiting the PWM path to BK7238 (#18958) --- esphome/components/remote_transmitter/__init__.py | 14 +++++--------- .../remote_transmitter/remote_transmitter.h | 9 +++++---- .../remote_transmitter_bk72xx.cpp | 11 +++++++---- .../remote_transmitter_libretiny_isr.cpp | 10 +++++----- .../remote_transmitter/test_non_blocking_gate.py | 2 +- .../remote_transmitter/test.bk72xx-ard.yaml | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index cb2aebec917..58392c48abc 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -4,11 +4,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base from esphome.components.libretiny import get_libretiny_family -from esphome.components.libretiny.const import ( - FAMILY_BK7231N, - FAMILY_BK7238, - FAMILY_RTL8720C, -) +from esphome.components.libretiny.const import FAMILY_BK7238, FAMILY_RTL8720C from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -49,7 +45,9 @@ DigitalWriteAction = remote_transmitter_ns.class_( ) -_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238) +# Keep in sync with the USE_LIBRETINY_VARIANT_RTL8720C / REMOTE_TRANSMITTER_BK_PWM gates in +# remote_transmitter.h, which decide where set_non_blocking() is declared +_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7238) def _validate_non_blocking_platform(value: bool) -> bool: @@ -59,9 +57,7 @@ def _validate_non_blocking_platform(value: bool) -> bool: return cv.boolean(value) if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES: return cv.boolean(value) - raise cv.Invalid( - "non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238" - ) + raise cv.Invalid("non_blocking is only supported on ESP32, RTL8720C and BK7238") MULTI_CONF = True diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 313b26364da..4db4e80a60e 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -12,10 +12,11 @@ #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 -// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven -// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate. -// See remote_transmitter_bk72xx.cpp. -#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238) +// Enables the ISR-driven transmitter on Beken. Gated on BK7238 alone: the shadow-load PWM +// block is shared with BK7231N, but LibreTiny builds that family against an older BDK whose +// PWM driver has no pwm_init_param()/pwm_start(). See remote_transmitter_bk72xx.cpp. +// Keep in sync with _NON_BLOCKING_LIBRETINY_FAMILIES in __init__.py. +#ifdef USE_LIBRETINY_VARIANT_BK7238 #define REMOTE_TRANSMITTER_BK_PWM #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp index 0081ae47b3f..822389ccf95 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -9,10 +9,13 @@ // with the core's fixes for type-name collisions between the two #include -// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit) -// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang -// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing. -// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h. +// Needs the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit) +// for glitch-free per-edge duty updates, and an SDK exposing pwm_init_param()/pwm_start(). +// BK7231N has the block but LibreTiny builds it against an older BDK offering only the +// sddev_control API (CMD_PWM_INIT_PARAM), so it stays on the generic bit-bang path until +// someone can add and validate that path on real hardware. Every other Beken SoC lacks the +// block. REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h; when it is +// unset this file compiles to nothing and remote_transmitter.cpp is used instead. namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index 003cdfa9865..fad91f593ff 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -3,11 +3,11 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -// Envelope chain shared by the LibreTiny families that pace transmission from a hardware -// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything -// platform-specific sits behind five hooks implemented in the per-family files -- carrier -// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep -// the generic bit-bang implementation and compile none of this. +// Envelope chain shared by the LibreTiny families that pace transmission from a hardware timer +// interrupt: RTL8720C (gtimer) and BK7238 (BKTIMER1). Everything platform-specific sits behind +// five hooks implemented in the per-family files -- carrier setup, duty writes, one-shot arming +// and timer stop. Families without a usable timer keep the generic bit-bang implementation and +// compile none of this. #if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM) namespace esphome::remote_transmitter { diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py index ee2769e1778..525ab3329ec 100644 --- a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -26,7 +26,7 @@ from ..types import SetCoreConfigCallable (PlatformFramework.ESP32_IDF, None, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False), - (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True), + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, False), (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True), (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False), (PlatformFramework.ESP8266_ARDUINO, None, False), diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml index ea2feafda91..f3e2da9daf4 100644 --- a/tests/components/remote_transmitter/test.bk72xx-ard.yaml +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -2,7 +2,7 @@ remote_transmitter: id: xmitr pin: GPIO26 carrier_duty_percent: 50% - # non_blocking is bk7231n/bk7238-only; the CI board is a BK7252 + # non_blocking is bk7238-only; the CI board is a BK7252, so this builds the bit-bang path packages: buttons: !include common-buttons.yaml From 657116a213de452fc191772c06e20aec09d16446 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:15:06 +0000 Subject: [PATCH 006/759] Bump bundled esphome-device-builder to 1.14.0 (#18960) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0da8048c574..79526164969 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.13.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0 RUN \ platformio settings set enable_telemetry No \ From e47247486ba238f16f958a3298e08c43d309e6c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Sep 2026 21:16:36 +0200 Subject: [PATCH 007/759] [esp8266] Drop Arduino framework versions before 3.0.0 (#18917) to Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/arduino8266/framework.py | 13 ++--- esphome/components/climate/climate.cpp | 4 +- esphome/components/debug/debug_component.cpp | 4 +- esphome/components/debug/debug_component.h | 4 +- esphome/components/debug/debug_esp8266.cpp | 2 - esphome/components/debug/sensor.py | 7 +-- esphome/components/esp8266/__init__.py | 57 ++++++------------- .../nextion/nextion_upload_arduino.cpp | 6 -- esphome/components/wifi/wifi_component.h | 5 -- .../wifi/wifi_component_esp8266.cpp | 10 +--- esphome/core/log.h | 14 ----- .../components/esp8266/test_boards.py | 17 +----- .../esp8266/test_framework_version.py | 23 ++++++++ .../unit_tests/test_arduino8266_framework.py | 17 ++---- 14 files changed, 62 insertions(+), 121 deletions(-) create mode 100644 tests/unit_tests/components/esp8266/test_framework_version.py diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 1edbe4b36f4..663002b3b14 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -44,8 +44,7 @@ def get_arduino8266_tools_path() -> Path: return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) -# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the -# encoder below cannot name 3.0.0/3.0.1 either (see its docstring) +# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0 MIN_FRAMEWORK_VERSION = Version(3, 1, 1) @@ -53,20 +52,16 @@ def framework_package_version(ver: Version) -> str: """Map an Arduino core version to its registry package version (3.1.2 -> 3.30102.0; the leading 3 is the package major). - Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor - at MIN_FRAMEWORK_VERSION. + Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION. """ if ver.major > 3: raise EsphomeError( f"Arduino core {ver} is not supported yet; " "the newest known core series is 3.x" ) - if ver <= Version(2, 6, 2): - # Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same - # boundary as _format_framework_arduino_version's era guard) + if ver.major < 3: raise EsphomeError( - f"Arduino core {ver} uses an older package encoding than this " - "helper implements (newer than 2.6.2)" + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" ) return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 34684a87e13..f80de151b11 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -368,8 +368,8 @@ optional Climate::restore_state_() { } void Climate::save_state_(const ClimateTraits &traits) { -#if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ - !defined(CLANG_TIDY) +#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY) +#pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wclass-memaccess" #define TEMP_IGNORE_MEMACCESS #endif diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 9020c261c26..97f4522c623 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -22,9 +22,9 @@ void DebugComponent::dump_config() { LOG_SENSOR(" ", "Free space on heap", this->free_sensor_); LOG_SENSOR(" ", "Largest free heap block", this->block_sensor_); LOG_SENSOR(" ", "CPU frequency", this->cpu_frequency_sensor_); -#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#ifdef USE_ESP8266 LOG_SENSOR(" ", "Heap fragmentation", this->fragmentation_sensor_); -#endif // defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#endif // USE_ESP8266 #endif // USE_SENSOR char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 20798cf6006..b05029f8785 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -35,7 +35,7 @@ class DebugComponent final : public PollingComponent { #ifdef USE_SENSOR void set_free_sensor(sensor::Sensor *free_sensor) { free_sensor_ = free_sensor; } void set_block_sensor(sensor::Sensor *block_sensor) { block_sensor_ = block_sensor; } -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) void set_fragmentation_sensor(sensor::Sensor *fragmentation_sensor) { fragmentation_sensor_ = fragmentation_sensor; } #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) @@ -61,7 +61,7 @@ class DebugComponent final : public PollingComponent { sensor::Sensor *free_sensor_{nullptr}; sensor::Sensor *block_sensor_{nullptr}; -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) sensor::Sensor *fragmentation_sensor_{nullptr}; #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 272123dfc0e..acce28818c7 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -159,12 +159,10 @@ void DebugComponent::update_platform_() { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->block_sensor_->publish_state(ESP.getMaxFreeBlockSize()); } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) if (this->fragmentation_sensor_ != nullptr) { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->fragmentation_sensor_->publish_state(ESP.getHeapFragmentation()); } -#endif #endif } diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 72e2efebc23..e53cb0d1e4a 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -52,12 +52,9 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_FRAGMENTATION): cv.All( cv.Any( - cv.All( - cv.only_on_esp8266, - cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)), - ), + cv.only_on_esp8266, cv.only_on_esp32, - msg="This feature is only available on ESP8266 (Arduino 2.5.2+) and ESP32", + msg="This feature is only available on ESP8266 and ESP32", ), sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 63665e76819..19dbb68f29a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -35,7 +35,7 @@ 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, board_ld_script +from .boards import BOARDS, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -43,8 +43,6 @@ from .const import ( CONF_RESTORE_FROM_FLASH, KEY_BOARD, KEY_ESP8266, - KEY_FLASH_SIZE, - KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -133,10 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 - if ver <= cv.Version(2, 4, 1): - return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - if ver <= cv.Version(2, 6, 2): - return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" # Same encoding the native toolchain uses for its package download, so a # version bump cannot drift between the two paths. from esphome.arduino8266.framework import framework_package_version @@ -159,11 +153,9 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # - https://github.com/esp8266/Arduino/releases # - https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 1, 2) -# The platformio/espressif8266 version to use for arduino 2 framework versions +# The platformio/espressif8266 version to use for arduino 3 framework versions # - https://github.com/platformio/platform-espressif8266/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif8266 -ARDUINO_2_PLATFORM_VERSION = cv.Version(2, 6, 3) -# for arduino 3 framework versions ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) # for arduino 4 framework versions ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) @@ -188,6 +180,14 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: version = cv.Version.parse(cv.version_number(value[CONF_VERSION])) source = value.get(CONF_SOURCE, None) + if version < cv.Version(3, 0, 0): + raise cv.Invalid( + f"Arduino framework {version} is no longer supported; ESPHome requires " + f"C++20, which needs Arduino core 3.x. Use the recommended version " + f"({RECOMMENDED_ARDUINO_FRAMEWORK_VERSION}).", + path=[CONF_VERSION], + ) + value[CONF_VERSION] = str(version) value[CONF_SOURCE] = source or _format_framework_arduino_version(version) @@ -195,12 +195,8 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: if platform_version is None: if version >= cv.Version(3, 1, 0): platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) - elif version >= cv.Version(3, 0, 0): - platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) - elif version >= cv.Version(2, 5, 0): - platform_version = _parse_platform_version(str(ARDUINO_2_PLATFORM_VERSION)) else: - platform_version = _parse_platform_version(str(cv.Version(1, 8, 0))) + platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION: @@ -289,29 +285,11 @@ def check_rosetta() -> None: ) -def _choose_ld_script(board: str, ver: cv.Version) -> str | None: - """The flash ld to pin for this board and core, or None for cores - without ld-script support.""" - board_data = BOARDS[board] - ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] - if ver <= cv.Version(2, 3, 0): - # No ld script support - return None - if ver <= cv.Version(2, 4, 2): - # Old ld script path; the modern per-board override names do not - # exist in this core's SDK, so the override cannot be honored. - # Substituting the size default would move _FS_end and the - # preferences sector, wiping flash-backed state on flash. - if KEY_LDSCRIPT in board_data: - raise EsphomeError( - f"Board {board} requires its {board_data[KEY_LDSCRIPT]} " - f"flash layout, which Arduino core {ver} cannot honor; " - "use a core newer than 2.4.2" - ) - return ld_scripts[0] +def _choose_ld_script(board: str) -> str: + """The flash ld to pin for this board.""" # A per-board override preserves a layout the board shipped with # (see d1_wroom_02 in boards.py) - return board_ld_script(board_data) + return board_ld_script(BOARDS[board]) @coroutine_with_priority(CoroPriority.PLATFORM) @@ -435,10 +413,9 @@ async def to_code(config: ConfigType) -> None: ) if config[CONF_BOARD] in BOARDS: - ld_script = _choose_ld_script(config[CONF_BOARD], ver) - - if ld_script is not None: - cg.add_platformio_option("board_build.ldscript", ld_script) + cg.add_platformio_option( + "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) + ) CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index f02f32d5ca2..944fa1db477 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -209,14 +209,8 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); -#elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) - http_client.setFollowRedirects(true); -#endif -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) http_client.setRedirectLimit(3); -#endif begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str()); if (!begin_status) { this->connection_state_.is_updating_ = false; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index cfdbc1a968e..63df9fbfa51 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -40,11 +40,6 @@ #include #include -#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(2, 4, 0) -extern "C" { -#include -}; -#endif #endif #ifdef USE_RP2 diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index b4a91fb3cd3..031da1b355f 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -21,7 +21,6 @@ extern "C" { #include "lwip/apps/sntp.h" #include "lwip/netif.h" // struct netif #include -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0) #include "LwipDhcpServer.h" #if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) #include @@ -30,7 +29,6 @@ extern "C" { #define wifi_softap_set_dhcps_lease_time(time) dhcpSoftAP.set_dhcps_lease_time(time) #define wifi_softap_set_dhcps_offer_option(offer, mode) dhcpSoftAP.set_dhcps_offer_option(offer, mode) #endif -#endif } #include "esphome/core/application.h" @@ -293,7 +291,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { conf.bssid_set = 0; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) if (ap.password_.empty()) { conf.threshold.authmode = AUTH_OPEN; } else { @@ -310,7 +307,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } } conf.threshold.rssi = -127; -#endif ETS_UART_INTR_DISABLE(); bool ret = wifi_station_set_config_current(&conf); @@ -602,7 +598,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { #endif break; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) case EVENT_OPMODE_CHANGED: { auto it = event->event_info.opmode_changed; ESP_LOGV(TAG, "Changed Mode old=%s new=%s", LOG_STR_ARG(get_op_mode_str(it.old_opmode)), @@ -620,7 +615,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { #endif break; } -#endif default: break; } @@ -705,7 +699,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.bssid = nullptr; config.channel = 0; config.show_hidden = 1; -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; // Use shorter dwell times for roaming scans - we only need to detect strong // nearby APs, not do a thorough survey. This also reduces off-channel time @@ -724,7 +717,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS; config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS; } -#endif bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback); if (!ret) { ESP_LOGV(TAG, "wifi_station_scan failed"); @@ -830,7 +822,7 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) +#if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) dhcpSoftAP.begin(&info); #endif diff --git a/esphome/core/log.h b/esphome/core/log.h index 272e516808a..14d24412ef6 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -18,7 +18,6 @@ #ifdef USE_STORE_LOG_STR_IN_FLASH #include "WString.h" -#include "esphome/core/defines.h" // for USE_ARDUINO_VERSION_CODE #endif // Include ESP-IDF/Arduino based logging methods here so they don't undefine ours later @@ -177,20 +176,7 @@ struct LogString; #include -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 0) #define LOG_STR_ARG(s) ((PGM_P) (s)) -#else -// Pre-Arduino 2.5, we can't pass a PSTR() to printf(). Emulate support by copying the message to a -// local buffer first. String length is limited to 63 characters. -// https://github.com/esp8266/Arduino/commit/6280e98b0360f85fdac2b8f10707fffb4f6e6e31 -#define LOG_STR_ARG(s) \ - ({ \ - char __buf[64]; \ - __buf[63] = '\0'; \ - strncpy_P(__buf, (PGM_P) (s), 63); \ - __buf; \ - }) -#endif #define LOG_STR(s) (reinterpret_cast(PSTR(s))) #define LOG_STR_LITERAL(s) LOG_STR_ARG(LOG_STR(s)) diff --git a/tests/unit_tests/components/esp8266/test_boards.py b/tests/unit_tests/components/esp8266/test_boards.py index df0e536d42a..78213a762a3 100644 --- a/tests/unit_tests/components/esp8266/test_boards.py +++ b/tests/unit_tests/components/esp8266/test_boards.py @@ -1,11 +1,7 @@ """Tests for the per-board linker-script rule.""" -import pytest - from esphome.components.esp8266 import _choose_ld_script from esphome.components.esp8266.boards import BOARDS, board_ld_script -import esphome.config_validation as cv -from esphome.core import EsphomeError def test_d1_wroom_02_keeps_its_shipped_layout() -> None: @@ -21,13 +17,6 @@ def test_default_boards_use_the_flash_size_layout() -> None: def test_choose_ld_script_paths() -> None: - """Old cores get the size default, overriding boards hard-error there - (a substituted layout would wipe flash-backed state), modern cores - honor the override.""" - assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None - assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld" - assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == ( - "eagle.flash.2m64.ld" - ) - with pytest.raises(EsphomeError, match="cannot honor"): - _choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2)) + """Default boards get the size layout, overriding boards keep theirs.""" + assert _choose_ld_script("nodemcuv2") == "eagle.flash.4m.ld" + assert _choose_ld_script("d1_wroom_02") == "eagle.flash.2m64.ld" diff --git a/tests/unit_tests/components/esp8266/test_framework_version.py b/tests/unit_tests/components/esp8266/test_framework_version.py new file mode 100644 index 00000000000..0107aff8dd3 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_framework_version.py @@ -0,0 +1,23 @@ +"""Tests for the Arduino framework version floor.""" + +import pytest + +from esphome.components.esp8266 import _arduino_check_versions +import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION + + +def test_versions_before_3_are_rejected() -> None: + with pytest.raises(cv.Invalid, match="no longer supported") as excinfo: + _arduino_check_versions({CONF_VERSION: "2.7.4"}) + assert excinfo.value.path == [CONF_VERSION] + + +def test_supported_versions_pass() -> None: + value = _arduino_check_versions({CONF_VERSION: "3.0.2"}) + assert value[CONF_VERSION] == "3.0.2" + assert "espressif8266@3.2.0" in value[CONF_PLATFORM_VERSION] + + value = _arduino_check_versions({CONF_VERSION: "recommended"}) + assert value[CONF_VERSION] == "3.1.2" + assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION] diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index bd0a620e10b..9f415344aea 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -21,17 +21,12 @@ def _build_path(tmp_path: Path) -> None: def test_framework_package_version() -> None: assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" - # 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path) - assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0" # A future major bump needs its own encoding, not a doomed registry lookup with pytest.raises(EsphomeError, match="not supported yet"): framework.framework_package_version(cv.Version(4, 0, 0)) - # The boundary matches the PlatformIO era guard; a 2.6.2 pre-release - # keeps this encoding - with pytest.raises(EsphomeError, match="older package encoding"): - framework.framework_package_version(cv.Version(2, 6, 2)) - assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0" - assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0" + # Cores before 3.x cannot build ESPHome (C++20) and are rejected + with pytest.raises(EsphomeError, match="requires core 3"): + framework.framework_package_version(cv.Version(2, 7, 4)) def test_format_framework_arduino_version_pins_all_series() -> None: @@ -39,10 +34,10 @@ def test_format_framework_arduino_version_pins_all_series() -> None: era, including the 4.x rejection it now shares with the installer.""" from esphome.components.esp8266 import _format_framework_arduino_version as fmt - assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0" - assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0" - assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0" assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0" + # Pre-3 cores are rejected with the version line anchored + with pytest.raises(cv.Invalid, match="requires core 3"): + fmt(cv.Version(2, 7, 4)) # Anchored to the framework version line, not a bare EsphomeError with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: fmt(cv.Version(4, 0, 0)) From cb0c2bdaca67440249b415f4bb831c3906b83bbe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:38:55 +1200 Subject: [PATCH 008/759] [esp32_ble] Reference count BLE advertising (#18943) --- esphome/components/esp32_ble/ble.cpp | 35 +++++++++++++++---- esphome/components/esp32_ble/ble.h | 13 +++++++ .../esp32_ble_beacon/esp32_ble_beacon.cpp | 2 ++ .../components/esp32_ble_server/__init__.py | 12 +++++++ .../esp32_ble_server/ble_server.cpp | 21 +++++++++-- .../components/esp32_ble_server/ble_server.h | 11 ++++++ .../esp32_improv/esp32_improv_component.cpp | 20 ++++++++++- .../esp32_improv/esp32_improv_component.h | 3 ++ .../esp32_ble_server/config/improv_only.yaml | 13 +++++++ .../config/manufacturer_data_only.yaml | 9 +++++ .../esp32_ble_server/config/own_service.yaml | 14 ++++++++ .../esp32_ble_server/test_esp32_ble_server.py | 28 +++++++++++++++ 12 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/config/improv_only.yaml create mode 100644 tests/component_tests/esp32_ble_server/config/manufacturer_data_only.yaml create mode 100644 tests/component_tests/esp32_ble_server/config/own_service.yaml diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6e6fb0e30d2..fc95760cf82 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -100,21 +100,38 @@ void ESP32BLE::disable() { #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); - if (!this->is_active()) + this->advertising_ref_count_++; + this->advertising_refresh(); +} + +void ESP32BLE::advertising_stop() { + if (this->advertising_ref_count_ == 0) return; - this->advertising_->start(); + this->advertising_ref_count_--; + this->advertising_refresh(); +} + +void ESP32BLE::advertising_refresh() { + if (this->advertising_ == nullptr || !this->is_active()) + return; + // Advertise while any component still needs it, otherwise stop + if (this->advertising_ref_count_ == 0) { + this->advertising_->stop(); + } else { + this->advertising_->start(); + } } void ESP32BLE::advertising_set_service_data(const std::vector &data) { this->advertising_init_(); this->advertising_->set_service_data(data); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_set_manufacturer_data(const std::vector &data) { this->advertising_init_(); this->advertising_->set_manufacturer_data(data); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_set_service_data_and_name(std::span data, bool include_name) { @@ -136,7 +153,7 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span da this->advertising_->set_service_data(data); } - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { @@ -147,13 +164,13 @@ void ESP32BLE::advertising_register_raw_advertisement_callback(std::functionadvertising_init_(); this->advertising_->add_service_uuid(uuid); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { this->advertising_init_(); this->advertising_->remove_service_uuid(uuid); - this->advertising_start(); + this->advertising_refresh(); } #endif @@ -575,6 +592,10 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { } this->state_ = BLE_COMPONENT_STATE_ACTIVE; +#ifdef USE_ESP32_BLE_ADVERTISING + // Requests made before the stack was up (or before it was re-enabled) take effect now + this->advertising_refresh(); +#endif } } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 2a355a6c8b6..7d2d0438a46 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -114,7 +114,17 @@ class ESP32BLE final : public Component { void set_name(const char *name) { this->name_ = name; } #ifdef USE_ESP32_BLE_ADVERTISING + /** Request advertising on behalf of a component. + * + * Requests are reference counted: advertising runs until every component that called + * advertising_start() has released it again with advertising_stop(). Each component must + * pair its calls, so nothing advertises until something actually asks for it. + */ void advertising_start(); + /// Release a request made with advertising_start(); advertising stops at the last release. + void advertising_stop(); + /// Apply the current payload and request count: advertise while requested, otherwise stop. + void advertising_refresh(); void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; } @@ -226,6 +236,9 @@ class ESP32BLE final : public Component { // 1-byte aligned members (grouped together to minimize padding) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte +#ifdef USE_ESP32_BLE_ADVERTISING + uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising +#endif #ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS optional auth_req_mode_; diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 9f1723430b4..ab728f9f6fc 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -67,6 +67,8 @@ void ESP32BLEBeacon::setup() { this->on_advertise_(); } }); + // A beacon always needs the device to advertise, and never releases the request + global_ble->advertising_start(); } void ESP32BLEBeacon::on_advertise_() { diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 855a3be29be..d8095cd7020 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -596,6 +596,18 @@ async def to_code(config): cg.add(var.set_parent(parent)) cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) + # Only advertise for the server itself when the configuration gives clients something to + # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # silent until that service asks for advertising. + cg.add( + var.set_advertising_required( + CONF_MANUFACTURER_DATA in config + or any( + not uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID) + for service_config in config[CONF_SERVICES] + ) + ) + ) if CONF_MANUFACTURER_DATA in config: cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA])) for service_config in config[CONF_SERVICES]: diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 2dea1666bbe..45679b9b982 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -81,6 +81,7 @@ void BLEServer::loop() { if (this->device_information_service_->is_running()) { this->state_ = RUNNING; this->restart_advertising_(); + this->request_advertising_(); ESP_LOGD(TAG, "BLE server setup successfully"); } else if (this->device_information_service_->is_created()) { this->device_information_service_->start(); @@ -98,6 +99,20 @@ void BLEServer::restart_advertising_() { } } +void BLEServer::request_advertising_() { + if (!this->advertising_required_ || this->advertising_requested_) + return; + this->advertising_requested_ = true; + this->parent_->advertising_start(); +} + +void BLEServer::release_advertising_() { + if (!this->advertising_requested_) + return; + this->advertising_requested_ = false; + this->parent_->advertising_stop(); +} + BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char uuid_buf[esp32_ble::UUID_STR_LEN]; @@ -170,7 +185,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga this->add_client_(param->connect.conn_id); // Resume advertising so additional clients can discover and connect if (this->client_count_ < this->max_clients_) { - this->parent_->advertising_start(); + this->parent_->advertising_refresh(); } this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id); break; @@ -178,7 +193,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_DISCONNECT_EVT: { ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); - this->parent_->advertising_start(); + this->parent_->advertising_refresh(); this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id); break; } @@ -226,6 +241,8 @@ void BLEServer::remove_client_(uint16_t conn_id) { } void BLEServer::ble_before_disabled_event_handler() { + // Advertising is re-requested once the server is running again after BLE is re-enabled + this->release_advertising_(); // Delete all clients this->client_count_ = 0; // Delete all services diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index fdd92812cd4..7869c73cc53 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -38,6 +38,13 @@ class BLEServer final : public Component, public Parented { this->restart_advertising_(); } + /** Whether this server needs the device to advertise so clients can find and connect to it. + * + * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * request advertising themselves for as long as they need it. + */ + void set_advertising_required(bool required) { this->advertising_required_ = required; } + void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; } uint8_t get_max_clients() const { return this->max_clients_; } @@ -82,6 +89,8 @@ class BLEServer final : public Component, public Parented { }; void restart_advertising_(); + void request_advertising_(); + void release_advertising_(); int8_t find_client_index_(uint16_t conn_id) const; void add_client_(uint16_t conn_id); @@ -93,6 +102,8 @@ class BLEServer final : public Component, public Parented { std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; bool registered_{false}; + bool advertising_required_{true}; + bool advertising_requested_{false}; uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{}; uint8_t client_count_{0}; diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 4756fba6375..9ec6eb7bab6 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -112,6 +112,7 @@ void ESP32ImprovComponent::loop() { this->state_callback_.call(this->state_, this->error_state_); #endif } + this->release_advertising_(); this->incoming_data_.clear(); return; } @@ -143,8 +144,9 @@ void ESP32ImprovComponent::loop() { ESP_LOGV(TAG, "Starting with device name advertising"); this->advertising_device_name_ = true; this->last_name_adv_time_ = App.get_loop_component_start_time(); + // Set the payload before requesting, so advertising starts exactly once esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); - esp32_ble::global_ble->advertising_start(); + this->request_advertising_(); // Set initial state based on whether we have an authorizer this->set_state_(this->get_initial_state_(), false); @@ -326,6 +328,8 @@ void ESP32ImprovComponent::stop() { this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; + // Release first so removing the service UUID does not restart advertising on the way out + this->release_advertising_(); this->service_->stop(); this->set_state_(improv::STATE_STOPPED); }); @@ -520,6 +524,20 @@ void ESP32ImprovComponent::update_advertising_type_() { } } +void ESP32ImprovComponent::request_advertising_() { + if (this->advertising_requested_) + return; + this->advertising_requested_ = true; + esp32_ble::global_ble->advertising_start(); +} + +void ESP32ImprovComponent::release_advertising_() { + if (!this->advertising_requested_) + return; + this->advertising_requested_ = false; + esp32_ble::global_ble->advertising_stop(); +} + improv::State ESP32ImprovComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 414948c9774..a40d60552a8 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -104,8 +104,11 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; bool advertising_device_name_{false}; + bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); + void request_advertising_(); + void release_advertising_(); void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); diff --git a/tests/component_tests/esp32_ble_server/config/improv_only.yaml b/tests/component_tests/esp32_ble_server/config/improv_only.yaml new file mode 100644 index 00000000000..8a5c3ba6383 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/improv_only.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32 + +wifi: + ssid: MySSID + password: password1 + +# esp32_ble_server is only auto-loaded here, so it has no services of its own. +esp32_improv: + authorizer: none diff --git a/tests/component_tests/esp32_ble_server/config/manufacturer_data_only.yaml b/tests/component_tests/esp32_ble_server/config/manufacturer_data_only.yaml new file mode 100644 index 00000000000..b7bdae4af7b --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/manufacturer_data_only.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + manufacturer_data: [0x72, 0x04, 0x00, 0x23] diff --git a/tests/component_tests/esp32_ble_server/config/own_service.yaml b/tests/component_tests/esp32_ble_server/config/own_service.yaml new file mode 100644 index 00000000000..c7ef0287b08 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/own_service.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 88307d0dcfc..4b7ab79a81c 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -1,5 +1,10 @@ """Tests for esp32_ble_server configuration helpers.""" +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + import pytest from esphome.components.esp32_ble_server import ( @@ -45,3 +50,26 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: assert uuid_is(uuid16, uuid16) assert uuid_is(f"{uuid16:04X}", uuid16) assert uuid_is(f"{uuid16:08X}", uuid16) + + +@pytest.mark.parametrize( + ("config_file", "required"), + [ + # Auto-loaded by esp32_improv only: nothing to find until Improv asks for it + ("improv_only.yaml", False), + # The configuration defines a service clients are meant to connect to + ("own_service.yaml", True), + # Manufacturer data is only useful if it is actually broadcast + ("manufacturer_data_only.yaml", True), + ], +) +def test_advertising_required( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + required: bool, +) -> None: + """The server only requests advertising when the configuration needs it.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert f"set_advertising_required({str(required).lower()})" in main_cpp From e36445fa5feb4db65586186ec823a225339b8885 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 5 Sep 2026 06:00:25 -0500 Subject: [PATCH 009/759] [usb_uart] Keep the comm interface number valid when its claim fails (#18968) --- esphome/components/usb_uart/usb_uart.cpp | 12 +++++++----- esphome/components/usb_uart/usb_uart.h | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index cf66e4c369a..60b7fe4e9c4 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -434,11 +434,12 @@ void USBUartTypeCdcAcm::on_connected() { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); if (err_comm != ESP_OK) { + // Continue anyway: the interface number stays valid for CDC request addressing ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, esp_err_to_name(err_comm)); - channel->cdc_dev_.interrupt_interface_number = 0xFF; // Mark as unavailable, but continue anyway } else { ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; } } auto err = @@ -465,14 +466,15 @@ void USBUartTypeCdcAcm::on_disconnected() { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); } - if (channel->cdc_dev_.notify_ep != nullptr) { + // Only tear down the notify pipe when we claimed its interface ourselves; + // no transfer is ever submitted on it, so there is nothing else to cancel. + if (channel->cdc_dev_.notify_ep != nullptr && channel->cdc_dev_.interrupt_interface_claimed) { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); } - if (channel->cdc_dev_.interrupt_interface_number != 0xFF && - channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { + if (channel->cdc_dev_.interrupt_interface_claimed) { usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_number = 0xFF; + channel->cdc_dev_.interrupt_interface_claimed = false; } usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.bulk_interface_number); // Reset the input and output started flags to their initial state to avoid the possibility of spurious restarts diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 00b34fb9427..9d87bf964c0 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -34,7 +34,10 @@ struct CdcEps { const usb_ep_desc_t *in_ep; const usb_ep_desc_t *out_ep; uint8_t bulk_interface_number; + // Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it + // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number; + bool interrupt_interface_claimed{false}; }; enum CH34xChipType : uint8_t { From 745eb3010910a400c14527d0dd8e1fd5fa7ac984 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:07:15 +0200 Subject: [PATCH 010/759] Bump bundled esphome-device-builder to 1.14.1 (#18981) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 79526164969..2d4ddbef5d4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.1 RUN \ platformio settings set enable_telemetry No \ From 7089dae3b63f57db6435202c14668001d0f0b595 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:25:58 +0000 Subject: [PATCH 011/759] Bump bundled esphome-device-builder to 1.14.2 (#18988) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2d4ddbef5d4..b5170864a34 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.2 RUN \ platformio settings set enable_telemetry No \ From 011497d6eeed511d55ec556db334f154f9dfc514 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:28:02 +0200 Subject: [PATCH 012/759] Bump bundled esphome-device-builder to 1.14.3 (#18996) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index b5170864a34..e875851bfbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.2 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.3 RUN \ platformio settings set enable_telemetry No \ From 18220e0b3940727d2f0657247ba0b749403b9db4 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz Date: Sun, 6 Sep 2026 23:03:07 +0200 Subject: [PATCH 013/759] [climate][template] New template climate component (#14455) --- esphome/components/climate/__init__.py | 13 + .../components/template/climate/__init__.py | 465 ++++++++++++++++++ .../components/template/climate/automation.h | 57 +++ .../template/climate/template_climate.cpp | 164 ++++++ .../template/climate/template_climate.h | 92 ++++ esphome/config_validation.py | 1 + .../template/test_template_climate.py | 145 ++++++ tests/components/climate/common.yaml | 3 +- tests/components/template/common-base.yaml | 113 +++++ .../fixtures/template_climate_basic.yaml | 72 +++ .../template_climate_custom_modes.yaml | 47 ++ .../template_climate_nonoptimistic.yaml | 56 +++ .../template_climate_on_control_ordering.yaml | 26 + .../template_climate_publish_all_fields.yaml | 63 +++ .../template_climate_sensor_push.yaml | 49 ++ .../template_climate_set_actions.yaml | 89 ++++ ...emplate_climate_two_point_temperature.yaml | 52 ++ .../test_template_climate_basic.py | 146 ++++++ .../test_template_climate_custom_modes.py | 98 ++++ .../test_template_climate_nonoptimistic.py | 107 ++++ ...st_template_climate_on_control_ordering.py | 83 ++++ ...est_template_climate_publish_all_fields.py | 96 ++++ .../test_template_climate_sensor_push.py | 88 ++++ .../test_template_climate_set_actions.py | 114 +++++ ..._template_climate_two_point_temperature.py | 118 +++++ 25 files changed, 2355 insertions(+), 2 deletions(-) create mode 100644 esphome/components/template/climate/__init__.py create mode 100644 esphome/components/template/climate/automation.h create mode 100644 esphome/components/template/climate/template_climate.cpp create mode 100644 esphome/components/template/climate/template_climate.h create mode 100644 tests/component_tests/template/test_template_climate.py create mode 100644 tests/integration/fixtures/template_climate_basic.yaml create mode 100644 tests/integration/fixtures/template_climate_custom_modes.yaml create mode 100644 tests/integration/fixtures/template_climate_nonoptimistic.yaml create mode 100644 tests/integration/fixtures/template_climate_on_control_ordering.yaml create mode 100644 tests/integration/fixtures/template_climate_publish_all_fields.yaml create mode 100644 tests/integration/fixtures/template_climate_sensor_push.yaml create mode 100644 tests/integration/fixtures/template_climate_set_actions.yaml create mode 100644 tests/integration/fixtures/template_climate_two_point_temperature.yaml create mode 100644 tests/integration/test_template_climate_basic.py create mode 100644 tests/integration/test_template_climate_custom_modes.py create mode 100644 tests/integration/test_template_climate_nonoptimistic.py create mode 100644 tests/integration/test_template_climate_on_control_ordering.py create mode 100644 tests/integration/test_template_climate_publish_all_fields.py create mode 100644 tests/integration/test_template_climate_sensor_push.py create mode 100644 tests/integration/test_template_climate_set_actions.py create mode 100644 tests/integration/test_template_climate_two_point_temperature.py diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 80dd913fba0..3fbca1a6d03 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -125,6 +125,19 @@ CLIMATE_SWING_MODES = { validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True) +ClimateAction = climate_ns.enum("ClimateAction") +CLIMATE_ACTIONS = { + "OFF": ClimateAction.CLIMATE_ACTION_OFF, + "COOLING": ClimateAction.CLIMATE_ACTION_COOLING, + "HEATING": ClimateAction.CLIMATE_ACTION_HEATING, + "IDLE": ClimateAction.CLIMATE_ACTION_IDLE, + "DRYING": ClimateAction.CLIMATE_ACTION_DRYING, + "FAN": ClimateAction.CLIMATE_ACTION_FAN, + "DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING, +} + +validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True) + CONF_MIN_HUMIDITY = "min_humidity" CONF_MAX_HUMIDITY = "max_humidity" CONF_TARGET_HUMIDITY = "target_humidity" diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py new file mode 100644 index 00000000000..c39ea8f80e9 --- /dev/null +++ b/esphome/components/template/climate/__init__.py @@ -0,0 +1,465 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import climate, sensor +from esphome.components.climate import climate_ns +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTION, + CONF_CURRENT_TEMPERATURE, + CONF_CUSTOM_FAN_MODE, + CONF_CUSTOM_FAN_MODES, + CONF_CUSTOM_PRESET, + CONF_CUSTOM_PRESETS, + CONF_FAN_MODE, + CONF_HUMIDITY_SENSOR, + CONF_ID, + CONF_INITIAL_STATE, + CONF_MODE, + CONF_OPTIMISTIC, + CONF_PRESET, + CONF_RESTORE_MODE, + CONF_SENSOR, + CONF_SUPPORTED_FAN_MODES, + CONF_SUPPORTED_MODES, + CONF_SUPPORTED_PRESETS, + CONF_SUPPORTED_SWING_MODES, + CONF_SWING_MODE, + CONF_TARGET_TEMPERATURE, + CONF_TARGET_TEMPERATURE_HIGH, + CONF_TARGET_TEMPERATURE_LOW, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +from .. import template_ns + +CONF_CURRENT_HUMIDITY = "current_humidity" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_SUPPORTS_ACTION = "supports_action" +CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = "supports_two_point_target_temperature" +CONF_SUPPORTS_TARGET_HUMIDITY = "supports_target_humidity" +CONF_SUPPORTS_CURRENT_TEMPERATURE = "supports_current_temperature" +CONF_SUPPORTS_CURRENT_HUMIDITY = "supports_current_humidity" +CONF_SET_MODE_ACTION = "set_mode_action" +CONF_SET_TARGET_TEMPERATURE_ACTION = "set_target_temperature_action" +CONF_SET_TARGET_TEMPERATURE_LOW_ACTION = "set_target_temperature_low_action" +CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION = "set_target_temperature_high_action" +CONF_SET_TARGET_HUMIDITY_ACTION = "set_target_humidity_action" +CONF_SET_FAN_MODE_ACTION = "set_fan_mode_action" +CONF_SET_CUSTOM_FAN_MODE_ACTION = "set_custom_fan_mode_action" +CONF_SET_SWING_MODE_ACTION = "set_swing_mode_action" +CONF_SET_PRESET_ACTION = "set_preset_action" +CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action" + +TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component) +TemplateClimatePublishAction = template_ns.class_( + "TemplateClimatePublishAction", + automation.Action, + cg.Parented.template(TemplateClimate), +) + +TemplateClimateRestoreMode = template_ns.enum( + "TemplateClimateRestoreMode", is_class=True +) +CLIMATE_RESTORE_MODES = { + "NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE, + "RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE, +} + +# Per-field actions that forward a requested value on. The third item is the type of `x`. +SET_ACTIONS = ( + (CONF_SET_MODE_ACTION, "get_set_mode_trigger", climate.ClimateMode), + ( + CONF_SET_TARGET_TEMPERATURE_ACTION, + "get_set_target_temperature_trigger", + cg.float_, + ), + ( + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION, + "get_set_target_temperature_low_trigger", + cg.float_, + ), + ( + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION, + "get_set_target_temperature_high_trigger", + cg.float_, + ), + (CONF_SET_TARGET_HUMIDITY_ACTION, "get_set_target_humidity_trigger", cg.float_), + (CONF_SET_FAN_MODE_ACTION, "get_set_fan_mode_trigger", climate.ClimateFanMode), + ( + CONF_SET_CUSTOM_FAN_MODE_ACTION, + "get_set_custom_fan_mode_trigger", + cg.StringRef, + ), + ( + CONF_SET_SWING_MODE_ACTION, + "get_set_swing_mode_trigger", + climate.ClimateSwingMode, + ), + (CONF_SET_PRESET_ACTION, "get_set_preset_trigger", climate.ClimatePreset), + (CONF_SET_CUSTOM_PRESET_ACTION, "get_set_custom_preset_trigger", cg.StringRef), +) + +# supports_* keys have no default so that an omitted key can mean "derive it from the sensor or +# set action that makes the trait useful", which is not expressible once a default fills it in. +DERIVED_SUPPORTS = ( + (CONF_SUPPORTS_CURRENT_TEMPERATURE, (CONF_SENSOR,)), + (CONF_SUPPORTS_CURRENT_HUMIDITY, (CONF_HUMIDITY_SENSOR,)), + ( + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE, + ( + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION, + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION, + ), + ), + (CONF_SUPPORTS_TARGET_HUMIDITY, (CONF_SET_TARGET_HUMIDITY_ACTION,)), +) + + +# Custom fan modes/presets are opaque user-defined strings with no build-time correctness check +# elsewhere (Climate::set_supported_custom_fan_modes()/set_supported_custom_presets() don't block +# empty entries), so reject empty ones here -- they could never be selected at runtime anyway. +validate_custom_climate_string = cv.All(cv.string_strict, cv.Length(min=1)) + + +def _validate_two_point(config: ConfigType) -> ConfigType: + has_low = CONF_TARGET_TEMPERATURE_LOW in config + has_high = CONF_TARGET_TEMPERATURE_HIGH in config + if has_low != has_high: + raise cv.Invalid( + f"'{CONF_TARGET_TEMPERATURE_LOW}' and '{CONF_TARGET_TEMPERATURE_HIGH}' must be used together" + ) + if (has_low or has_high) and CONF_TARGET_TEMPERATURE in config: + raise cv.Invalid( + f"'{CONF_TARGET_TEMPERATURE}' cannot be used together with " + f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}'" + ) + return config + + +def _validate_set_actions(config: ConfigType) -> ConfigType: + has_low = CONF_SET_TARGET_TEMPERATURE_LOW_ACTION in config + has_high = CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION in config + if has_low != has_high: + raise cv.Invalid( + f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}' and " + f"'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}' must be used together" + ) + if (has_low or has_high) and CONF_SET_TARGET_TEMPERATURE_ACTION in config: + raise cv.Invalid( + f"'{CONF_SET_TARGET_TEMPERATURE_ACTION}' cannot be used together with " + f"'{CONF_SET_TARGET_TEMPERATURE_LOW_ACTION}'/'{CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION}'" + ) + return config + + +def _resolve_supports(config: ConfigType) -> ConfigType: + # An explicit true stays valid without either, since climate.template.publish can report the + # value; an explicit false that contradicts the configuration is an error, not a silent override. + for key, sources in DERIVED_SUPPORTS: + configured = [source for source in sources if source in config] + if key not in config: + config[key] = bool(configured) + elif not config[key] and configured: + raise cv.Invalid( + f"'{key}' cannot be false while '{configured[0]}' is configured", + path=[key], + ) + return config + + +def _validate_initial_state(config: ConfigType) -> ConfigType: + # Climate keeps target_temperature and target_temperature_low in a union, so writing the wrong + # one of the pair corrupts the setpoint with no runtime complaint. + if (initial_state := config.get(CONF_INITIAL_STATE)) is None: + return config + + two_point = config[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] + if two_point and CONF_TARGET_TEMPERATURE in initial_state: + raise cv.Invalid( + f"'{CONF_TARGET_TEMPERATURE}' is not available while " + f"'{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' is enabled; use " + f"'{CONF_TARGET_TEMPERATURE_LOW}'/'{CONF_TARGET_TEMPERATURE_HIGH}' instead", + path=[CONF_INITIAL_STATE, CONF_TARGET_TEMPERATURE], + ) + if not two_point: + for key in (CONF_TARGET_TEMPERATURE_LOW, CONF_TARGET_TEMPERATURE_HIGH): + if key in initial_state: + raise cv.Invalid( + f"'{key}' requires '{CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE}' to be enabled", + path=[CONF_INITIAL_STATE, key], + ) + if ( + CONF_TARGET_HUMIDITY in initial_state + and not config[CONF_SUPPORTS_TARGET_HUMIDITY] + ): + raise cv.Invalid( + f"'{CONF_TARGET_HUMIDITY}' requires '{CONF_SUPPORTS_TARGET_HUMIDITY}' to be enabled", + path=[CONF_INITIAL_STATE, CONF_TARGET_HUMIDITY], + ) + return config + + +# Same settable fields as climate.template.publish, minus current_temperature/current_humidity/ +# action: those are reported values (from a sensor or the device), not meaningful static defaults. +INITIAL_STATE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_MODE): climate.validate_climate_mode, + cv.Optional(CONF_TARGET_TEMPERATURE): cv.temperature, + cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.temperature, + cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TARGET_HUMIDITY): cv.percentage_int, + cv.Exclusive(CONF_FAN_MODE, "fan_mode"): climate.validate_climate_fan_mode, + cv.Exclusive( + CONF_CUSTOM_FAN_MODE, "fan_mode" + ): validate_custom_climate_string, + cv.Optional(CONF_SWING_MODE): climate.validate_climate_swing_mode, + cv.Exclusive(CONF_PRESET, "preset"): climate.validate_climate_preset, + cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): validate_custom_climate_string, + } + ), + _validate_two_point, +) + +CONFIG_SCHEMA = cv.All( + climate.climate_schema(TemplateClimate) + .extend( + { + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), + # action only ever arrives through climate.template.publish, so unlike the other + # supports_* keys there is no set action to derive it from. + cv.Optional(CONF_SUPPORTS_ACTION, default=False): cv.boolean, + cv.Optional(CONF_SUPPORTS_CURRENT_TEMPERATURE): cv.boolean, + cv.Optional(CONF_SUPPORTS_CURRENT_HUMIDITY): cv.boolean, + cv.Optional(CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE): cv.boolean, + cv.Optional(CONF_SUPPORTS_TARGET_HUMIDITY): cv.boolean, + cv.Required(CONF_SUPPORTED_MODES): cv.All( + cv.ensure_list(climate.validate_climate_mode), cv.Unique() + ), + cv.Optional(CONF_SUPPORTED_FAN_MODES): cv.All( + cv.ensure_list(climate.validate_climate_fan_mode), cv.Unique() + ), + cv.Optional(CONF_CUSTOM_FAN_MODES): cv.All( + cv.ensure_list(validate_custom_climate_string), cv.Unique() + ), + cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.All( + cv.ensure_list(climate.validate_climate_swing_mode), cv.Unique() + ), + cv.Optional(CONF_SUPPORTED_PRESETS): cv.All( + cv.ensure_list(climate.validate_climate_preset), cv.Unique() + ), + cv.Optional(CONF_CUSTOM_PRESETS): cv.All( + cv.ensure_list(validate_custom_climate_string), cv.Unique() + ), + cv.Optional(CONF_OPTIMISTIC, default=True): cv.boolean, + cv.Optional(CONF_RESTORE_MODE, default="RESTORE"): cv.enum( + CLIMATE_RESTORE_MODES, upper=True + ), + cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA, + cv.Optional(CONF_SET_MODE_ACTION): automation.validate_automation( + single=True + ), + cv.Optional( + CONF_SET_TARGET_TEMPERATURE_ACTION + ): automation.validate_automation(single=True), + cv.Optional( + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION + ): automation.validate_automation(single=True), + cv.Optional( + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION + ): automation.validate_automation(single=True), + cv.Optional( + CONF_SET_TARGET_HUMIDITY_ACTION + ): automation.validate_automation(single=True), + cv.Optional(CONF_SET_FAN_MODE_ACTION): automation.validate_automation( + single=True + ), + cv.Optional( + CONF_SET_CUSTOM_FAN_MODE_ACTION + ): automation.validate_automation(single=True), + cv.Optional(CONF_SET_SWING_MODE_ACTION): automation.validate_automation( + single=True + ), + cv.Optional(CONF_SET_PRESET_ACTION): automation.validate_automation( + single=True + ), + cv.Optional(CONF_SET_CUSTOM_PRESET_ACTION): automation.validate_automation( + single=True + ), + } + ) + .extend(cv.COMPONENT_SCHEMA), + _validate_set_actions, + _resolve_supports, + _validate_initial_state, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await climate.register_climate(var, config) + + if (sens := config.get(CONF_SENSOR)) is not None: + cg.add(var.set_sensor(await cg.get_variable(sens))) + + if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None: + cg.add(var.set_humidity_sensor(await cg.get_variable(sens))) + + for key, flag in ( + (CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION), + ( + CONF_SUPPORTS_CURRENT_TEMPERATURE, + climate_ns.CLIMATE_SUPPORTS_CURRENT_TEMPERATURE, + ), + (CONF_SUPPORTS_CURRENT_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_CURRENT_HUMIDITY), + ( + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE, + climate_ns.CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE, + ), + (CONF_SUPPORTS_TARGET_HUMIDITY, climate_ns.CLIMATE_SUPPORTS_TARGET_HUMIDITY), + ): + if config[key]: + cg.add(var.add_feature_flags(flag)) + + for mode in config[CONF_SUPPORTED_MODES]: + cg.add(var.add_supported_mode(mode)) + + for mode in config.get(CONF_SUPPORTED_FAN_MODES, []): + cg.add(var.add_supported_fan_mode(mode)) + + if CONF_CUSTOM_FAN_MODES in config: + cg.add( + var.set_supported_custom_fan_modes( + cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES]) + ) + ) + + for mode in config.get(CONF_SUPPORTED_SWING_MODES, []): + cg.add(var.add_supported_swing_mode(mode)) + + for preset in config.get(CONF_SUPPORTED_PRESETS, []): + cg.add(var.add_supported_preset(preset)) + + if CONF_CUSTOM_PRESETS in config: + cg.add( + var.set_supported_custom_presets( + cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS]) + ) + ) + + for key, trigger_getter, arg_type in SET_ACTIONS: + if (conf := config.get(key)) is not None: + await automation.build_automation( + getattr(var, trigger_getter)(), [(arg_type, "x")], conf + ) + + cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) + cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) + + if (initial_state := config.get(CONF_INITIAL_STATE)) is not None: + if (v := initial_state.get(CONF_MODE)) is not None: + cg.add(var.set_mode(v)) + if (v := initial_state.get(CONF_TARGET_TEMPERATURE)) is not None: + cg.add(var.set_target_temperature(v)) + if (v := initial_state.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: + cg.add(var.set_target_temperature_low(v)) + if (v := initial_state.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: + cg.add(var.set_target_temperature_high(v)) + if (v := initial_state.get(CONF_TARGET_HUMIDITY)) is not None: + cg.add(var.set_target_humidity(v)) + if (v := initial_state.get(CONF_FAN_MODE)) is not None: + cg.add(var.set_fan_mode(v)) + if (v := initial_state.get(CONF_CUSTOM_FAN_MODE)) is not None: + cg.add(var.set_custom_fan_mode(v)) + if (v := initial_state.get(CONF_SWING_MODE)) is not None: + cg.add(var.set_swing_mode(v)) + if (v := initial_state.get(CONF_PRESET)) is not None: + cg.add(var.set_preset(v)) + if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None: + cg.add(var.set_custom_preset(v)) + + +CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(TemplateClimate), + cv.Optional(CONF_CURRENT_TEMPERATURE): cv.templatable(cv.temperature), + cv.Optional(CONF_CURRENT_HUMIDITY): cv.templatable(cv.percentage_int), + cv.Optional(CONF_TARGET_TEMPERATURE): cv.templatable(cv.temperature), + cv.Optional(CONF_TARGET_TEMPERATURE_LOW): cv.templatable(cv.temperature), + cv.Optional(CONF_TARGET_TEMPERATURE_HIGH): cv.templatable(cv.temperature), + cv.Optional(CONF_TARGET_HUMIDITY): cv.templatable(cv.percentage_int), + cv.Optional(CONF_MODE): cv.templatable(climate.validate_climate_mode), + cv.Optional(CONF_ACTION): cv.templatable(climate.validate_climate_action), + cv.Exclusive(CONF_FAN_MODE, "fan_mode"): cv.templatable( + climate.validate_climate_fan_mode + ), + cv.Exclusive(CONF_CUSTOM_FAN_MODE, "fan_mode"): cv.templatable( + validate_custom_climate_string + ), + cv.Optional(CONF_SWING_MODE): cv.templatable( + climate.validate_climate_swing_mode + ), + cv.Exclusive(CONF_PRESET, "preset"): cv.templatable( + climate.validate_climate_preset + ), + cv.Exclusive(CONF_CUSTOM_PRESET, "preset"): cv.templatable( + validate_custom_climate_string + ), + } + ), + _validate_two_point, +) + + +@automation.register_action( + "climate.template.publish", + TemplateClimatePublishAction, + CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA, + synchronous=True, +) +async def climate_template_publish_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None: + cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_))) + if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None: + cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_))) + if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None: + cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_))) + if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: + cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_))) + if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: + cg.add( + var.set_target_temperature_high(await cg.templatable(v, args, cg.float_)) + ) + if (v := config.get(CONF_TARGET_HUMIDITY)) is not None: + cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_))) + if (v := config.get(CONF_MODE)) is not None: + cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode))) + if (v := config.get(CONF_ACTION)) is not None: + cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction))) + if (v := config.get(CONF_FAN_MODE)) is not None: + cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode))) + if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None: + cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string))) + if (v := config.get(CONF_SWING_MODE)) is not None: + cg.add( + var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode)) + ) + if (v := config.get(CONF_PRESET)) is not None: + cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset))) + if (v := config.get(CONF_CUSTOM_PRESET)) is not None: + cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string))) + + return var diff --git a/esphome/components/template/climate/automation.h b/esphome/components/template/climate/automation.h new file mode 100644 index 00000000000..49a79ace2f1 --- /dev/null +++ b/esphome/components/template/climate/automation.h @@ -0,0 +1,57 @@ +#pragma once + +#include "template_climate.h" +#include "esphome/core/automation.h" + +namespace esphome::template_ { + +template +class TemplateClimatePublishAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, current_temperature) + TEMPLATABLE_VALUE(float, current_humidity) + TEMPLATABLE_VALUE(float, target_temperature) + TEMPLATABLE_VALUE(float, target_temperature_low) + TEMPLATABLE_VALUE(float, target_temperature_high) + TEMPLATABLE_VALUE(float, target_humidity) + TEMPLATABLE_VALUE(climate::ClimateMode, mode) + TEMPLATABLE_VALUE(climate::ClimateAction, action) + TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode) + TEMPLATABLE_VALUE(std::string, custom_fan_mode) + TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode) + TEMPLATABLE_VALUE(climate::ClimatePreset, preset) + TEMPLATABLE_VALUE(std::string, custom_preset) + + void play(const Ts &...x) override { + if (this->current_temperature_.has_value()) + this->parent_->current_temperature = this->current_temperature_.value(x...); + if (this->current_humidity_.has_value()) + this->parent_->current_humidity = this->current_humidity_.value(x...); + if (this->target_temperature_.has_value()) + this->parent_->set_target_temperature(this->target_temperature_.value(x...)); + if (this->target_temperature_low_.has_value()) + this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...)); + if (this->target_temperature_high_.has_value()) + this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...)); + if (this->target_humidity_.has_value()) + this->parent_->set_target_humidity(this->target_humidity_.value(x...)); + if (this->mode_.has_value()) + this->parent_->set_mode(this->mode_.value(x...)); + if (this->action_.has_value()) + this->parent_->action = this->action_.value(x...); + if (this->fan_mode_.has_value()) + this->parent_->set_fan_mode(this->fan_mode_.value(x...)); + if (this->custom_fan_mode_.has_value()) + this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...))); + if (this->swing_mode_.has_value()) + this->parent_->set_swing_mode(this->swing_mode_.value(x...)); + if (this->preset_.has_value()) + this->parent_->set_preset(this->preset_.value(x...)); + if (this->custom_preset_.has_value()) + this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...))); + + this->parent_->publish_state(); + } +}; + +} // namespace esphome::template_ diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp new file mode 100644 index 00000000000..a7a4d2ccabf --- /dev/null +++ b/esphome/components/template/climate/template_climate.cpp @@ -0,0 +1,164 @@ +#include "template_climate.h" +#include "esphome/core/log.h" + +namespace esphome::template_ { + +static const char *const TAG = "template.climate"; + +void TemplateClimate::setup() { + if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(this); + } + } + + // Sensors publish every reading, not just changes, so only re-publish when the value moved. + // NAN means the sensor went unavailable and is passed through rather than dropped; the second + // check stops an unavailable sensor re-publishing forever, since NAN never equals NAN. +#ifdef USE_SENSOR + if (this->sensor_ != nullptr) { + this->current_temperature = this->sensor_->state; + this->sensor_->add_on_state_callback([this](float state) { + if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) { + this->current_temperature = state; + this->publish_state(); + } + }); + } + + if (this->humidity_sensor_ != nullptr) { + this->current_humidity = this->humidity_sensor_->state; + this->humidity_sensor_->add_on_state_callback([this](float state) { + if (state != this->current_humidity && !(std::isnan(state) && std::isnan(this->current_humidity))) { + this->current_humidity = state; + this->publish_state(); + } + }); + } +#endif +} + +void TemplateClimate::dump_config() { + LOG_CLIMATE("", "Template Climate", this); + ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); +} + +void TemplateClimate::control(const climate::ClimateCall &call) { + // Each field present fires its set_*_action; on_control sees the whole call. optimistic: true + // also applies the values right away, false waits for a climate.template.publish report. + if (auto mode = call.get_mode()) { + if (this->optimistic_) + this->mode = *mode; + this->set_mode_trigger_.trigger(*mode); + } + + if (auto target_temp = call.get_target_temperature()) { + if (this->optimistic_) + this->target_temperature = *target_temp; + this->set_target_temperature_trigger_.trigger(*target_temp); + } + + if (auto target_temp_low = call.get_target_temperature_low()) { + if (this->optimistic_) + this->target_temperature_low = *target_temp_low; + this->set_target_temperature_low_trigger_.trigger(*target_temp_low); + } + + if (auto target_temp_high = call.get_target_temperature_high()) { + if (this->optimistic_) + this->target_temperature_high = *target_temp_high; + this->set_target_temperature_high_trigger_.trigger(*target_temp_high); + } + + if (auto target_humidity = call.get_target_humidity()) { + if (this->optimistic_) + this->target_humidity = *target_humidity; + this->set_target_humidity_trigger_.trigger(*target_humidity); + } + + if (auto fan_mode = call.get_fan_mode()) { + if (this->optimistic_) + this->set_fan_mode_(*fan_mode); + this->set_fan_mode_trigger_.trigger(*fan_mode); + } + + if (call.has_custom_fan_mode()) { + if (this->optimistic_) + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + this->set_custom_fan_mode_trigger_.trigger(call.get_custom_fan_mode()); + } + + if (auto swing_mode = call.get_swing_mode()) { + if (this->optimistic_) + this->swing_mode = *swing_mode; + this->set_swing_mode_trigger_.trigger(*swing_mode); + } + + if (auto preset = call.get_preset()) { + if (this->optimistic_) + this->set_preset_(*preset); + this->set_preset_trigger_.trigger(*preset); + } + + if (call.has_custom_preset()) { + if (this->optimistic_) + this->set_custom_preset_(call.get_custom_preset()); + this->set_custom_preset_trigger_.trigger(call.get_custom_preset()); + } + + if (this->optimistic_) + this->publish_state(); +} + +// A climate.template.publish report (and initial_state:) never goes through ClimateCall::validate_(), +// so check here instead -- otherwise a typo is published as state the receiving end will reject. +void TemplateClimate::set_mode(climate::ClimateMode mode) { + if (!this->traits_.supports_mode(mode)) { + ESP_LOGW(TAG, "'%s' - Unsupported mode %u", this->get_name().c_str(), static_cast(mode)); + return; + } + this->mode = mode; +} + +void TemplateClimate::set_swing_mode(climate::ClimateSwingMode swing_mode) { + if (!this->traits_.supports_swing_mode(swing_mode)) { + ESP_LOGW(TAG, "'%s' - Unsupported swing mode %u", this->get_name().c_str(), static_cast(swing_mode)); + return; + } + this->swing_mode = swing_mode; +} + +void TemplateClimate::set_fan_mode(climate::ClimateFanMode fan_mode) { + if (!this->traits_.supports_fan_mode(fan_mode)) { + ESP_LOGW(TAG, "'%s' - Unsupported fan mode %u", this->get_name().c_str(), static_cast(fan_mode)); + return; + } + this->set_fan_mode_(fan_mode); +} + +void TemplateClimate::set_preset(climate::ClimatePreset preset) { + if (!this->traits_.supports_preset(preset)) { + ESP_LOGW(TAG, "'%s' - Unsupported preset %u", this->get_name().c_str(), static_cast(preset)); + return; + } + this->set_preset_(preset); +} + +void TemplateClimate::set_custom_fan_mode(StringRef mode) { + if (this->find_custom_fan_mode_(mode.c_str(), mode.size()) == nullptr) { + ESP_LOGW(TAG, "'%s' - Unsupported custom fan mode '%s'", this->get_name().c_str(), mode.c_str()); + return; + } + this->set_custom_fan_mode_(mode); +} + +void TemplateClimate::set_custom_preset(StringRef preset) { + if (this->find_custom_preset_(preset.c_str(), preset.size()) == nullptr) { + ESP_LOGW(TAG, "'%s' - Unsupported custom preset '%s'", this->get_name().c_str(), preset.c_str()); + return; + } + this->set_custom_preset_(preset); +} + +} // namespace esphome::template_ diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h new file mode 100644 index 00000000000..5448488c345 --- /dev/null +++ b/esphome/components/template/climate/template_climate.h @@ -0,0 +1,92 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/components/climate/climate.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +namespace esphome::template_ { + +enum class TemplateClimateRestoreMode { + TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE, + TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE, +}; + +class TemplateClimate final : public climate::Climate, public Component { + public: + void setup() override; + void dump_config() override; + + climate::ClimateTraits traits() override { return this->traits_; } + + void add_feature_flags(uint32_t flags) { this->traits_.add_feature_flags(flags); } + +#ifdef USE_SENSOR + // The matching feature flag is added from codegen, so the configuration alone decides it. + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } +#endif + + void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); } + void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); } + void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); } + void add_supported_preset(climate::ClimatePreset preset) { this->traits_.add_supported_preset(preset); } + + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_restore_mode(TemplateClimateRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + + // Fired from control() for each field the call carries, so a device-backed config can forward + // it on. Which of these are configured also decides the two-point/target-humidity traits. + Trigger *get_set_mode_trigger() { return &this->set_mode_trigger_; } + Trigger *get_set_target_temperature_trigger() { return &this->set_target_temperature_trigger_; } + Trigger *get_set_target_temperature_low_trigger() { return &this->set_target_temperature_low_trigger_; } + Trigger *get_set_target_temperature_high_trigger() { return &this->set_target_temperature_high_trigger_; } + Trigger *get_set_target_humidity_trigger() { return &this->set_target_humidity_trigger_; } + Trigger *get_set_fan_mode_trigger() { return &this->set_fan_mode_trigger_; } + Trigger *get_set_custom_fan_mode_trigger() { return &this->set_custom_fan_mode_trigger_; } + Trigger *get_set_swing_mode_trigger() { return &this->set_swing_mode_trigger_; } + Trigger *get_set_preset_trigger() { return &this->set_preset_trigger_; } + Trigger *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; } + + // Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the + // protected setters, and by codegen to apply `initial_state:` before setup() runs. + void set_target_temperature(float value) { this->target_temperature = value; } + void set_target_temperature_low(float value) { this->target_temperature_low = value; } + void set_target_temperature_high(float value) { this->target_temperature_high = value; } + void set_target_humidity(float value) { this->target_humidity = value; } + void set_mode(climate::ClimateMode mode); + void set_swing_mode(climate::ClimateSwingMode mode); + void set_fan_mode(climate::ClimateFanMode mode); + void set_custom_fan_mode(const char *mode) { this->set_custom_fan_mode(StringRef(mode)); } + void set_custom_fan_mode(StringRef mode); + void set_preset(climate::ClimatePreset preset); + void set_custom_preset(const char *preset) { this->set_custom_preset(StringRef(preset)); } + void set_custom_preset(StringRef preset); + + protected: + void control(const climate::ClimateCall &call) override; + + climate::ClimateTraits traits_; + bool optimistic_{false}; + TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE}; + +#ifdef USE_SENSOR + sensor::Sensor *sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; +#endif + + Trigger set_mode_trigger_; + Trigger set_target_temperature_trigger_; + Trigger set_target_temperature_low_trigger_; + Trigger set_target_temperature_high_trigger_; + Trigger set_target_humidity_trigger_; + Trigger set_fan_mode_trigger_; + Trigger set_custom_fan_mode_trigger_; + Trigger set_swing_mode_trigger_; + Trigger set_preset_trigger_; + Trigger set_custom_preset_trigger_; +}; + +} // namespace esphome::template_ diff --git a/esphome/config_validation.py b/esphome/config_validation.py index aff39201e8d..685a9d04b3f 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -133,6 +133,7 @@ Upper = vol.Upper Length = vol.Length Exclusive = vol.Exclusive Inclusive = vol.Inclusive +Unique = vol.Unique ALLOW_EXTRA = vol.ALLOW_EXTRA UNDEFINED = vol.UNDEFINED RequiredFieldInvalid = vol.RequiredFieldInvalid diff --git a/tests/component_tests/template/test_template_climate.py b/tests/component_tests/template/test_template_climate.py new file mode 100644 index 00000000000..304991ea642 --- /dev/null +++ b/tests/component_tests/template/test_template_climate.py @@ -0,0 +1,145 @@ +"""Tests for template climate config validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.template.climate import ( + CONF_SET_TARGET_HUMIDITY_ACTION, + CONF_SET_TARGET_TEMPERATURE_ACTION, + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION, + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION, + CONF_SUPPORTS_CURRENT_HUMIDITY, + CONF_SUPPORTS_CURRENT_TEMPERATURE, + CONF_SUPPORTS_TARGET_HUMIDITY, + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE, + CONF_TARGET_HUMIDITY, + _resolve_supports, + _validate_initial_state, + _validate_set_actions, +) +from esphome.const import ( + CONF_HUMIDITY_SENSOR, + CONF_INITIAL_STATE, + CONF_SENSOR, + CONF_TARGET_TEMPERATURE, + CONF_TARGET_TEMPERATURE_HIGH, + CONF_TARGET_TEMPERATURE_LOW, +) +from esphome.types import ConfigType + + +def test_supports_current_temperature_derived_from_sensor() -> None: + config: ConfigType = {CONF_SENSOR: "some_sensor"} + assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True + + +def test_supports_current_temperature_false_without_sensor() -> None: + assert _resolve_supports({})[CONF_SUPPORTS_CURRENT_TEMPERATURE] is False + + +def test_supports_current_temperature_explicit_true_without_sensor_allowed() -> None: + # The value can still be reported with climate.template.publish. + config: ConfigType = {CONF_SUPPORTS_CURRENT_TEMPERATURE: True} + assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True + + +def test_supports_current_temperature_false_with_sensor_rejected() -> None: + config: ConfigType = { + CONF_SENSOR: "some_sensor", + CONF_SUPPORTS_CURRENT_TEMPERATURE: False, + } + with pytest.raises(cv.Invalid, match="cannot be false"): + _resolve_supports(config) + + +def test_supports_current_humidity_false_with_sensor_rejected() -> None: + config: ConfigType = { + CONF_HUMIDITY_SENSOR: "some_sensor", + CONF_SUPPORTS_CURRENT_HUMIDITY: False, + } + with pytest.raises(cv.Invalid, match="cannot be false"): + _resolve_supports(config) + + +def test_two_point_derived_from_set_actions() -> None: + config: ConfigType = { + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}], + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}], + } + assert _resolve_supports(config)[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] is True + + +def test_two_point_false_with_set_action_rejected() -> None: + config: ConfigType = { + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}], + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False, + } + with pytest.raises(cv.Invalid, match="cannot be false"): + _resolve_supports(config) + + +def test_target_humidity_derived_from_set_action() -> None: + config: ConfigType = {CONF_SET_TARGET_HUMIDITY_ACTION: [{}]} + assert _resolve_supports(config)[CONF_SUPPORTS_TARGET_HUMIDITY] is True + + +def test_set_target_temperature_low_requires_high() -> None: + config: ConfigType = {CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}]} + with pytest.raises(cv.Invalid, match="must be used together"): + _validate_set_actions(config) + + +def test_set_target_temperature_conflicts_with_two_point_actions() -> None: + config: ConfigType = { + CONF_SET_TARGET_TEMPERATURE_ACTION: [{}], + CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}], + CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}], + } + with pytest.raises(cv.Invalid, match="cannot be used together"): + _validate_set_actions(config) + + +def test_initial_state_target_temperature_rejected_with_two_point() -> None: + config: ConfigType = { + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True, + CONF_SUPPORTS_TARGET_HUMIDITY: False, + CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: 21.0}, + } + with pytest.raises(cv.Invalid, match="is not available"): + _validate_initial_state(config) + + +def test_initial_state_two_point_values_rejected_without_two_point() -> None: + config: ConfigType = { + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False, + CONF_SUPPORTS_TARGET_HUMIDITY: False, + CONF_INITIAL_STATE: { + CONF_TARGET_TEMPERATURE_LOW: 18.0, + CONF_TARGET_TEMPERATURE_HIGH: 24.0, + }, + } + with pytest.raises(cv.Invalid, match="requires"): + _validate_initial_state(config) + + +def test_initial_state_target_humidity_rejected_without_support() -> None: + config: ConfigType = { + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False, + CONF_SUPPORTS_TARGET_HUMIDITY: False, + CONF_INITIAL_STATE: {CONF_TARGET_HUMIDITY: 50}, + } + with pytest.raises(cv.Invalid, match="requires"): + _validate_initial_state(config) + + +def test_initial_state_matching_two_point_accepted() -> None: + config: ConfigType = { + CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True, + CONF_SUPPORTS_TARGET_HUMIDITY: True, + CONF_INITIAL_STATE: { + CONF_TARGET_TEMPERATURE_LOW: 18.0, + CONF_TARGET_TEMPERATURE_HIGH: 24.0, + CONF_TARGET_HUMIDITY: 50, + }, + } + assert _validate_initial_state(config) is config diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index c28fde8eeb4..49386a16d59 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -30,8 +30,7 @@ climate: - switch.turn_on: climate_heater_switch - switch.turn_off: climate_cooler_switch # Thermostat-based climate so climate.control: action variants get build - # coverage (bang_bang doesn't support fan modes, presets, etc.). Climate - # has no template platform, so thermostat is the right vehicle. + # coverage (bang_bang doesn't support fan modes, presets, etc.). - platform: thermostat id: climate_test_thermostat name: Test Thermostat diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 92a1fc8eda4..02aedaf167d 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -25,6 +25,27 @@ esphome: away: !lambda "return true;" is_on: !lambda "return false;" + - climate.template.publish: + id: template_climate + current_temperature: 21.0 + mode: HEAT + fan_mode: AUTO + swing_mode: "OFF" + preset: NONE + target_temperature: 22.0 + + # Templated + - climate.template.publish: + id: template_climate + current_temperature: !lambda "return 21.5f;" + mode: !lambda "return climate::CLIMATE_MODE_COOL;" + target_temperature: !lambda "return 23.0f;" + + - climate.template.publish: + id: template_climate_custom_modes + custom_fan_mode: "turbo" + custom_preset: "eco_plus" + # Test C++ API: set_template() with stateless lambda (no captures) # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. - lambda: |- @@ -513,6 +534,98 @@ alarm_control_panel: codes: - "1234" +climate: + - platform: template + id: template_climate + name: "Template Climate" + optimistic: true + sensor: template_template_sens + supports_action: true + supports_current_humidity: true + restore_mode: NO_RESTORE + initial_state: + mode: HEAT + target_temperature: 21.0 + fan_mode: LOW + supported_modes: + - "OFF" + - HEAT + - COOL + supported_fan_modes: + - AUTO + - LOW + - HIGH + supported_swing_modes: + - "OFF" + - VERTICAL + supported_presets: + - NONE + - ECO + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + set_mode_action: + - logger.log: + format: "set_mode_action %d" + args: ["(int) x"] + set_target_temperature_action: + - logger.log: + format: "set_target_temperature_action %.1f" + args: ["x"] + set_target_humidity_action: + - logger.log: + format: "set_target_humidity_action %.1f" + args: ["x"] + set_fan_mode_action: + - logger.log: + format: "set_fan_mode_action %d" + args: ["(int) x"] + set_swing_mode_action: + - logger.log: + format: "set_swing_mode_action %d" + args: ["(int) x"] + set_preset_action: + - logger.log: + format: "set_preset_action %d" + args: ["(int) x"] + on_control: + - logger.log: "on_control fired" + on_state: + - logger.log: "on_state fired" + + - platform: template + id: template_climate_custom_modes + name: "Template Climate Custom Modes" + optimistic: true + sensor: template_template_sens + supported_modes: + - "OFF" + - HEAT + custom_fan_modes: + - turbo + - silent + - eco + custom_presets: + - eco_plus + - power_save + - max + set_custom_fan_mode_action: + - logger.log: + format: "set_custom_fan_mode_action %s" + args: ["x.c_str()"] + set_custom_preset_action: + - logger.log: + format: "set_custom_preset_action %s" + args: ["x.c_str()"] + initial_state: + custom_fan_mode: eco + custom_preset: max + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + water_heater: - platform: template id: template_water_heater diff --git a/tests/integration/fixtures/template_climate_basic.yaml b/tests/integration/fixtures/template_climate_basic.yaml new file mode 100644 index 00000000000..51558b4875b --- /dev/null +++ b/tests/integration/fixtures/template_climate_basic.yaml @@ -0,0 +1,72 @@ +esphome: + name: tmpl-clim-basic + on_boot: + - climate.template.publish: + id: test_climate + action: IDLE +host: +api: +logger: + +climate: + - platform: template + id: test_climate + name: Test Basic Climate + optimistic: true + sensor: test_climate_current_temperature + humidity_sensor: test_climate_current_humidity + supports_action: true + supported_modes: + - "OFF" + - HEAT + - COOL + supported_fan_modes: + - AUTO + - LOW + - HIGH + supported_swing_modes: + - "OFF" + - VERTICAL + supported_presets: + - NONE + - ECO + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode()); + if (x.get_target_temperature().has_value()) + ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature()); + if (x.get_fan_mode().has_value()) + ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode()); + if (x.get_swing_mode().has_value()) + ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode()); + if (x.get_preset().has_value()) + ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset()); + +sensor: + - platform: template + id: test_climate_current_temperature + name: Test Climate Current Temperature + lambda: "return 22.5f;" + update_interval: 10ms + - platform: template + id: test_climate_current_humidity + name: Test Climate Current Humidity + lambda: "return 55.0f;" + update_interval: 10ms + +button: + - platform: template + id: simulate_device_report + name: Simulate Device Report + on_press: + - climate.template.publish: + id: test_climate + mode: "OFF" + fan_mode: AUTO + swing_mode: "OFF" + preset: NONE diff --git a/tests/integration/fixtures/template_climate_custom_modes.yaml b/tests/integration/fixtures/template_climate_custom_modes.yaml new file mode 100644 index 00000000000..9dbfe60cb98 --- /dev/null +++ b/tests/integration/fixtures/template_climate_custom_modes.yaml @@ -0,0 +1,47 @@ +esphome: + name: tmpl-clim-custom +host: +api: +logger: + +climate: + - platform: template + id: test_climate + name: Test Custom Mode Climate + optimistic: true + sensor: test_climate_current_temperature + supported_modes: + - "OFF" + - HEAT + - COOL + custom_fan_modes: + - turbo + - silent + - eco + custom_presets: + - eco_plus + - power_save + - max + on_control: + - lambda: |- + if (x.has_custom_fan_mode()) + ESP_LOGD("test", "on_control custom_fan_mode=%s", x.get_custom_fan_mode().c_str()); + if (x.has_custom_preset()) + ESP_LOGD("test", "on_control custom_preset=%s", x.get_custom_preset().c_str()); + +sensor: + - platform: template + id: test_climate_current_temperature + name: Test Climate Current Temperature + lambda: "return 22.5f;" + update_interval: 10ms + +button: + - platform: template + id: simulate_device_report + name: Simulate Device Report + on_press: + - climate.template.publish: + id: test_climate + custom_fan_mode: "eco" + custom_preset: "max" diff --git a/tests/integration/fixtures/template_climate_nonoptimistic.yaml b/tests/integration/fixtures/template_climate_nonoptimistic.yaml new file mode 100644 index 00000000000..2b0c7ee1321 --- /dev/null +++ b/tests/integration/fixtures/template_climate_nonoptimistic.yaml @@ -0,0 +1,56 @@ +esphome: + name: tmpl-clim-nonopt +host: +api: +logger: + +climate: + - platform: template + id: test_climate + name: Test Template Climate Nonoptimistic + optimistic: false + supported_modes: + - "OFF" + - HEAT + - COOL + - FAN_ONLY + supported_fan_modes: + - AUTO + - LOW + - HIGH + supported_swing_modes: + - "OFF" + - VERTICAL + supported_presets: + - NONE + - ECO + - AWAY + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode()); + if (x.get_target_temperature().has_value()) + ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature()); + if (x.get_fan_mode().has_value()) + ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode()); + if (x.get_swing_mode().has_value()) + ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode()); + if (x.get_preset().has_value()) + ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset()); + +button: + - platform: template + id: simulate_device_confirmation + name: Simulate Device Confirmation + on_press: + - climate.template.publish: + id: test_climate + mode: HEAT + target_temperature: 22.5 + fan_mode: HIGH + swing_mode: VERTICAL + preset: AWAY diff --git a/tests/integration/fixtures/template_climate_on_control_ordering.yaml b/tests/integration/fixtures/template_climate_on_control_ordering.yaml new file mode 100644 index 00000000000..8366a6d21e6 --- /dev/null +++ b/tests/integration/fixtures/template_climate_on_control_ordering.yaml @@ -0,0 +1,26 @@ +esphome: + name: tmpl-clim-oc-order +host: +api: +logger: + +# on_control fires with the full ClimateCall (arg `x`) from the base Climate component's +# ClimateCall::perform(), before validate_()/control() run -- so when the lambda action below +# runs, the entity's own .mode is still the OLD value, even though x.get_mode() already reports +# the NEW requested value. on_state fires afterward, once control() has applied it. +climate: + - platform: template + id: test_climate + name: Test On Control Ordering + optimistic: true + supported_modes: + - "OFF" + - HEAT + on_control: + - lambda: |- + ESP_LOGD("test", "on_control requested_mode=%d current_mode_before_apply=%d", + x.get_mode().has_value() ? (int) *x.get_mode() : -1, + (int) id(test_climate).mode); + on_state: + - lambda: |- + ESP_LOGD("test", "on_state mode=%d", (int) x.mode); diff --git a/tests/integration/fixtures/template_climate_publish_all_fields.yaml b/tests/integration/fixtures/template_climate_publish_all_fields.yaml new file mode 100644 index 00000000000..e57fcc4508f --- /dev/null +++ b/tests/integration/fixtures/template_climate_publish_all_fields.yaml @@ -0,0 +1,63 @@ +esphome: + name: tmpl-clim-publish-all +host: +api: +logger: + +climate: + - platform: template + id: test_climate + name: Test Publish All Fields + optimistic: true + # current_temperature/current_humidity/action are only sent over the API at all if their + # trait is advertised: current_temperature/current_humidity because a sensor/humidity_sensor + # is referenced below, action because supports_action is set. The sensors' fixed readings + # match what climate.template.publish pushes, so the sensor callback (guarded to only publish + # on an actual change) doesn't produce an extra, unexpected state update of its own. + sensor: test_climate_current_temperature + humidity_sensor: test_climate_current_humidity + supports_action: true + supported_modes: + - "OFF" + - HEAT + supported_fan_modes: + - AUTO + - HIGH + supported_swing_modes: + - "OFF" + - VERTICAL + supported_presets: + - NONE + - ECO + on_control: + # Should never fire in this test: climate.template.publish is a pure bypass and must not + # re-trigger on_control as if the entity were freshly commanded. + - logger.log: "on_control fired" + +sensor: + - platform: template + id: test_climate_current_temperature + name: Test Climate Current Temperature + lambda: "return 20.0f;" + update_interval: 10ms + - platform: template + id: test_climate_current_humidity + name: Test Climate Current Humidity + lambda: "return 60.0f;" + update_interval: 10ms + +button: + - platform: template + id: publish_all + name: Publish All + on_press: + - climate.template.publish: + id: test_climate + current_temperature: 20.0 + current_humidity: 60.0 + target_temperature: 23.0 + mode: HEAT + action: HEATING + fan_mode: HIGH + swing_mode: VERTICAL + preset: ECO diff --git a/tests/integration/fixtures/template_climate_sensor_push.yaml b/tests/integration/fixtures/template_climate_sensor_push.yaml new file mode 100644 index 00000000000..1fc004335d2 --- /dev/null +++ b/tests/integration/fixtures/template_climate_sensor_push.yaml @@ -0,0 +1,49 @@ +esphome: + name: tmpl-clim-sensor-push +host: +api: +logger: + +# No lambda/update_interval: these sensors only ever report a value when a button below +# publishes one (standing in for e.g. a BLE scan callback in a real config). +sensor: + - platform: template + id: room_temperature + name: Room Temperature + - platform: template + id: room_humidity + name: Room Humidity + +climate: + - platform: template + id: test_climate + name: Test Sensor Push Climate + optimistic: true + sensor: room_temperature + humidity_sensor: room_humidity + supported_modes: + - "OFF" + - HEAT + +button: + - platform: template + id: publish_temperature + name: Publish Temperature + on_press: + - sensor.template.publish: + id: room_temperature + state: 24.0 + - platform: template + id: publish_temperature_same + name: Publish Temperature Same Value + on_press: + - sensor.template.publish: + id: room_temperature + state: 24.0 + - platform: template + id: publish_humidity + name: Publish Humidity + on_press: + - sensor.template.publish: + id: room_humidity + state: 65.0 diff --git a/tests/integration/fixtures/template_climate_set_actions.yaml b/tests/integration/fixtures/template_climate_set_actions.yaml new file mode 100644 index 00000000000..b247367f642 --- /dev/null +++ b/tests/integration/fixtures/template_climate_set_actions.yaml @@ -0,0 +1,89 @@ +esphome: + name: tmpl-clim-set-act +host: +api: +logger: + +# Every settable field forwards its requested value to a set_*_action. supports_two_point and +# supports_target_humidity are not declared here: they are derived from the low/high and humidity +# set actions being present. +climate: + - platform: template + id: test_climate + name: Test Set Actions + optimistic: false + restore_mode: NO_RESTORE + supported_modes: + - "OFF" + - HEAT + - COOL + supported_fan_modes: + - AUTO + - LOW + supported_swing_modes: + - "OFF" + - VERTICAL + supported_presets: + - NONE + - ECO + custom_fan_modes: + - turbo + custom_presets: + - eco_plus + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + set_mode_action: + - logger.log: + format: "set_mode_action %d" + args: ["(int) x"] + set_target_temperature_low_action: + - logger.log: + format: "set_target_temperature_low_action %.1f" + args: ["x"] + set_target_temperature_high_action: + - logger.log: + format: "set_target_temperature_high_action %.1f" + args: ["x"] + set_target_humidity_action: + - logger.log: + format: "set_target_humidity_action %.0f" + args: ["x"] + set_fan_mode_action: + - logger.log: + format: "set_fan_mode_action %d" + args: ["(int) x"] + set_custom_fan_mode_action: + - logger.log: + format: "set_custom_fan_mode_action %s" + args: ["x.c_str()"] + set_swing_mode_action: + - logger.log: + format: "set_swing_mode_action %d" + args: ["(int) x"] + set_preset_action: + - logger.log: + format: "set_preset_action %d" + args: ["(int) x"] + set_custom_preset_action: + - logger.log: + format: "set_custom_preset_action %s" + args: ["x.c_str()"] + +button: + - platform: template + id: report_device_state + name: Report Device State + on_press: + - climate.template.publish: + id: test_climate + mode: HEAT + + - platform: template + id: report_unsupported_mode + name: Report Unsupported Mode + on_press: + - climate.template.publish: + id: test_climate + mode: DRY diff --git a/tests/integration/fixtures/template_climate_two_point_temperature.yaml b/tests/integration/fixtures/template_climate_two_point_temperature.yaml new file mode 100644 index 00000000000..ec10785ee89 --- /dev/null +++ b/tests/integration/fixtures/template_climate_two_point_temperature.yaml @@ -0,0 +1,52 @@ +esphome: + name: tmpl-clim-two-point +host: +api: +logger: + +climate: + - platform: template + id: test_climate + name: Test Two-Point Heatpump + optimistic: true + sensor: test_climate_current_temperature + supports_two_point_target_temperature: true + supports_target_humidity: true + supported_modes: + - "OFF" + - HEAT_COOL + - HEAT + - COOL + visual: + min_temperature: 16.0 + max_temperature: 30.0 + temperature_step: 0.5 + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode()); + if (x.get_target_temperature_low().has_value()) + ESP_LOGD("test", "on_control target_temperature_low=%.1f", *x.get_target_temperature_low()); + if (x.get_target_temperature_high().has_value()) + ESP_LOGD("test", "on_control target_temperature_high=%.1f", *x.get_target_temperature_high()); + if (x.get_target_humidity().has_value()) + ESP_LOGD("test", "on_control target_humidity=%.1f", *x.get_target_humidity()); + +sensor: + - platform: template + id: test_climate_current_temperature + name: Test Climate Current Temperature + lambda: "return 21.0f;" + update_interval: 10ms + +button: + - platform: template + id: simulate_device_report + name: Simulate Device Report + on_press: + - climate.template.publish: + id: test_climate + mode: HEAT_COOL + target_temperature_low: 18.0 + target_temperature_high: 24.0 + target_humidity: 50.0 diff --git a/tests/integration/test_template_climate_basic.py b/tests/integration/test_template_climate_basic.py new file mode 100644 index 00000000000..431fd4e3e87 --- /dev/null +++ b/tests/integration/test_template_climate_basic.py @@ -0,0 +1,146 @@ +"""Integration test for template climate: sensor-pushed measured values, on_control + publish +for the settable ones. + +current_temperature/current_humidity are pushed by a referenced sensor/humidity_sensor (no +polling); action is set once at boot via climate.template.publish, since it has no sensor +equivalent. mode/target_temperature/fan_mode/swing_mode/preset are plain internal state: +on_control fires exactly once per command (never before the first one), and +climate.template.publish simulates the device reporting its own state independent of any prior +command -- that report is authoritative, overriding whatever was optimistically applied earlier. +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ( + ButtonInfo, + ClimateAction, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateSwingMode, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-basic" + + +@pytest.mark.asyncio +async def test_template_climate_basic( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Sensor-pushed measured values, on_control + publish for settable ones.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + if "on_control " in line: + log_lines.append(line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + async def wait_for_climate_state( + timeout: float = 5.0, + ) -> aioesphomeapi.ClimateState: + return await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout + ) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + # Advertised capabilities come straight from the supported_*/custom_* config lists. + assert ClimateMode.OFF in test_climate.supported_modes + assert ClimateMode.HEAT in test_climate.supported_modes + assert ClimateMode.COOL in test_climate.supported_modes + + assert ClimateFanMode.AUTO in test_climate.supported_fan_modes + assert ClimateFanMode.LOW in test_climate.supported_fan_modes + assert ClimateFanMode.HIGH in test_climate.supported_fan_modes + + assert ClimateSwingMode.OFF in test_climate.supported_swing_modes + assert ClimateSwingMode.VERTICAL in test_climate.supported_swing_modes + + assert ClimatePreset.NONE in test_climate.supported_presets + assert ClimatePreset.ECO in test_climate.supported_presets + + report_button = require_entity(entities, "simulate_device_report", ButtonInfo) + + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda state: None) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + initial = initial_state_helper.initial_states.get(test_climate.key) + assert initial is not None, "No initial climate state received" + assert isinstance(initial, aioesphomeapi.ClimateState) + assert initial.current_temperature == pytest.approx(22.5, abs=0.1) + assert initial.current_humidity == pytest.approx(55.0, abs=0.1) + assert initial.action == ClimateAction.IDLE + assert initial.mode == ClimateMode.OFF + # Nothing was commanded yet: on_control must not have fired. + assert not log_lines + + # Commands apply optimistically and on_control fires with the same values. + client.climate_command(test_climate.key, mode=ClimateMode.HEAT) + state = await wait_for_climate_state() + assert state.mode == ClimateMode.HEAT + + client.climate_command(test_climate.key, target_temperature=22.5) + state = await wait_for_climate_state() + assert state.target_temperature == pytest.approx(22.5, abs=0.1) + + client.climate_command(test_climate.key, fan_mode=ClimateFanMode.HIGH) + state = await wait_for_climate_state() + assert state.fan_mode == ClimateFanMode.HIGH + + client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL) + state = await wait_for_climate_state() + assert state.swing_mode == ClimateSwingMode.VERTICAL + + client.climate_command(test_climate.key, preset=ClimatePreset.ECO) + state = await wait_for_climate_state() + assert state.preset == ClimatePreset.ECO + + await asyncio.sleep(0.2) + assert any( + "on_control mode=3" in line for line in log_lines + ) # CLIMATE_MODE_HEAT + assert any("on_control target_temperature=22.5" in line for line in log_lines) + assert any("on_control fan_mode=" in line for line in log_lines) + assert any("on_control swing_mode=" in line for line in log_lines) + assert any("on_control preset=" in line for line in log_lines) + # Exactly one on_control log line per command, none extra (e.g. from a stray republish). + assert len(log_lines) == 5 + + # measured values are untouched by any of the above (no set action exists for them). + assert state.current_temperature == pytest.approx(22.5, abs=0.1) + assert state.current_humidity == pytest.approx(55.0, abs=0.1) + assert state.action == ClimateAction.IDLE + + # The device's report is authoritative and overrides everything commanded above. + client.button_command(report_button.key) + state = await wait_for_climate_state() + assert state.mode == ClimateMode.OFF + assert state.fan_mode == ClimateFanMode.AUTO + assert state.swing_mode == ClimateSwingMode.OFF + assert state.preset == ClimatePreset.NONE diff --git a/tests/integration/test_template_climate_custom_modes.py b/tests/integration/test_template_climate_custom_modes.py new file mode 100644 index 00000000000..4817fe1ddfc --- /dev/null +++ b/tests/integration/test_template_climate_custom_modes.py @@ -0,0 +1,98 @@ +"""Integration test for template climate: custom fan modes and presets. + +Same on_control (forward) + climate.template.publish (device report, authoritative) pattern as +the enum-based mode/preset fields, but for the custom string variants. +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ButtonInfo, ClimateInfo +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-custom" + + +@pytest.mark.asyncio +async def test_template_climate_custom_modes( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Custom fan mode/preset: traits, on_control forwarding, and publish precedence.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + if "on_control " in line: + log_lines.append(line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + async def wait_for_climate_state( + timeout: float = 5.0, + ) -> aioesphomeapi.ClimateState: + return await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout + ) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + assert set(test_climate.supported_custom_fan_modes) == { + "turbo", + "silent", + "eco", + } + assert set(test_climate.supported_custom_presets) == { + "eco_plus", + "power_save", + "max", + } + + report_button = require_entity(entities, "simulate_device_report", ButtonInfo) + + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda state: None) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + initial = initial_state_helper.initial_states.get(test_climate.key) + assert initial is not None, "No initial climate state received" + assert isinstance(initial, aioesphomeapi.ClimateState) + assert initial.custom_fan_mode == "" + assert initial.custom_preset == "" + + client.climate_command(test_climate.key, custom_fan_mode="turbo") + state = await wait_for_climate_state() + assert state.custom_fan_mode == "turbo" + + client.climate_command(test_climate.key, custom_preset="power_save") + state = await wait_for_climate_state() + assert state.custom_preset == "power_save" + + await asyncio.sleep(0.2) + assert any("on_control custom_fan_mode=turbo" in line for line in log_lines) + assert any("on_control custom_preset=power_save" in line for line in log_lines) + + # The device's report is authoritative and overrides what was commanded above. + client.button_command(report_button.key) + state = await wait_for_climate_state() + assert state.custom_fan_mode == "eco" + assert state.custom_preset == "max" diff --git a/tests/integration/test_template_climate_nonoptimistic.py b/tests/integration/test_template_climate_nonoptimistic.py new file mode 100644 index 00000000000..e922ec31b9c --- /dev/null +++ b/tests/integration/test_template_climate_nonoptimistic.py @@ -0,0 +1,107 @@ +"""Integration test for template climate: optimistic: false. + +A command still fires on_control (so a real device-backed config can forward it out), but must +NOT change the entity's own state -- only an explicit climate.template.publish call (standing in +for the device confirming the command actually took effect) does that. +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ( + ButtonInfo, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateSwingMode, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-nonopt" + + +@pytest.mark.asyncio +async def test_template_climate_nonoptimistic( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Nonoptimistic: a command doesn't change state until explicitly published.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + state_updates: list[aioesphomeapi.ClimateState] = [] + + def on_log_line(line: str) -> None: + if "on_control " in line: + log_lines.append(line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + def on_state(state: aioesphomeapi.EntityState) -> None: + if isinstance(state, aioesphomeapi.ClimateState): + state_updates.append(state) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + confirm_button = require_entity( + entities, "simulate_device_confirmation", ButtonInfo + ) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + initial = initial_state_helper.initial_states.get(test_climate.key) + assert initial is not None, "No initial climate state received" + assert isinstance(initial, aioesphomeapi.ClimateState) + assert initial.mode == ClimateMode.OFF + + # Send every settable field in one command. on_control must fire with all of them, but + # nothing may be applied to the entity's own state -- no ClimateState update at all. + client.climate_command( + test_climate.key, + mode=ClimateMode.HEAT, + target_temperature=22.5, + fan_mode=ClimateFanMode.HIGH, + swing_mode=ClimateSwingMode.VERTICAL, + preset=ClimatePreset.AWAY, + ) + await asyncio.sleep(0.3) + assert any( + "on_control mode=3" in line for line in log_lines + ) # CLIMATE_MODE_HEAT + assert any("on_control target_temperature=22.5" in line for line in log_lines) + assert any("on_control fan_mode=" in line for line in log_lines) + assert any("on_control swing_mode=" in line for line in log_lines) + assert any("on_control preset=" in line for line in log_lines) + assert not state_updates, ( + "optimistic: false must not publish a state until climate.template.publish reports it" + ) + + # The device confirms the command actually took effect. + client.button_command(confirm_button.key) + state = await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState) + ) + assert state.mode == ClimateMode.HEAT + assert state.target_temperature == pytest.approx(22.5, abs=0.1) + assert state.fan_mode == ClimateFanMode.HIGH + assert state.swing_mode == ClimateSwingMode.VERTICAL + assert state.preset == ClimatePreset.AWAY diff --git a/tests/integration/test_template_climate_on_control_ordering.py b/tests/integration/test_template_climate_on_control_ordering.py new file mode 100644 index 00000000000..8d212b3ccba --- /dev/null +++ b/tests/integration/test_template_climate_on_control_ordering.py @@ -0,0 +1,83 @@ +"""Integration test: on_control fires before control()/on_state, with the full ClimateCall. + +on_control's lambda argument exposes get_mode()/etc. on the *requested* ClimateCall, while the +entity's own .mode field still reflects the state *before* control() applies the change -- +proving the firing order is on_control, then control(), then on_state. +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ClimateInfo, ClimateMode +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-oc-order" + + +@pytest.mark.asyncio +async def test_template_climate_on_control_ordering( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """on_control sees the requested value while the entity's own state is still the old one.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + if "on_control " in line or "on_state " in line: + log_lines.append(line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + async def wait_for_climate_state( + timeout: float = 5.0, + ) -> aioesphomeapi.ClimateState: + return await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout + ) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda state: None) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + client.climate_command(test_climate.key, mode=ClimateMode.HEAT) + state = await wait_for_climate_state() + assert state.mode == ClimateMode.HEAT + + await asyncio.sleep(0.2) + + # on_control saw the new requested mode (3 == CLIMATE_MODE_HEAT) while the entity's own + # state was still the old one (0 == CLIMATE_MODE_OFF) -- proving it fired before control(). + assert any( + "on_control requested_mode=3 current_mode_before_apply=0" in line + for line in log_lines + ) + # on_state fired afterward, reporting the now-applied mode. + assert any("on_state mode=3" in line for line in log_lines) + + control_index = next( + i for i, line in enumerate(log_lines) if "on_control " in line + ) + state_index = next(i for i, line in enumerate(log_lines) if "on_state " in line) + assert control_index < state_index, "on_control must fire before on_state" diff --git a/tests/integration/test_template_climate_publish_all_fields.py b/tests/integration/test_template_climate_publish_all_fields.py new file mode 100644 index 00000000000..9c4262b3113 --- /dev/null +++ b/tests/integration/test_template_climate_publish_all_fields.py @@ -0,0 +1,96 @@ +"""Integration test for template climate: climate.template.publish covering every field at once. + +A single climate.template.publish call resolves into exactly one ClimateState update, and never +triggers on_control (which would misrepresent a device state report as a fresh command). This also +exercises that a sensor/humidity_sensor whose reading matches what's about to be published doesn't +sneak in an extra state update of its own (the sensor callback only re-publishes on an actual +change). +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ( + ButtonInfo, + ClimateAction, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateSwingMode, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-publish-all" + + +@pytest.mark.asyncio +async def test_template_climate_publish_all_fields( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """One climate.template.publish call setting every field resolves to one state update.""" + clear_host_prefs(DEVICE_NAME) + + state_updates: list[aioesphomeapi.ClimateState] = [] + on_control_count = 0 + + def on_log_line(line: str) -> None: + nonlocal on_control_count + if "on_control fired" in line: + on_control_count += 1 + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + def on_state(state: aioesphomeapi.EntityState) -> None: + if isinstance(state, aioesphomeapi.ClimateState): + state_updates.append(state) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + + publish_button = require_entity(entities, "publish_all", ButtonInfo) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + client.button_command(publish_button.key) + try: + state = await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState) + ) + except TimeoutError: + pytest.fail("Timeout waiting for the published climate state") + + assert state.current_temperature == pytest.approx(20.0, abs=0.1) + assert state.current_humidity == pytest.approx(60.0, abs=0.1) + assert state.target_temperature == pytest.approx(23.0, abs=0.1) + assert state.mode == ClimateMode.HEAT + assert state.action == ClimateAction.HEATING + assert state.fan_mode == ClimateFanMode.HIGH + assert state.swing_mode == ClimateSwingMode.VERTICAL + assert state.preset == ClimatePreset.ECO + + # Give any stray extra update (there shouldn't be one) a moment to arrive. + await asyncio.sleep(0.2) + assert len(state_updates) == 1, ( + f"Expected exactly one ClimateState update, got {len(state_updates)}" + ) + assert on_control_count == 0, ( + "climate.template.publish must not trigger on_control" + ) diff --git a/tests/integration/test_template_climate_sensor_push.py b/tests/integration/test_template_climate_sensor_push.py new file mode 100644 index 00000000000..1db4da81edb --- /dev/null +++ b/tests/integration/test_template_climate_sensor_push.py @@ -0,0 +1,88 @@ +"""Integration test for template climate: current_temperature/current_humidity live sensor push. + +A *later* change to a backing sensor's value -- not just its initial reading at boot -- propagates +into a new climate state via add_on_state_callback. Re-publishing the same sensor value again must +not cause a redundant climate state update. +""" + +from __future__ import annotations + +import asyncio +import math + +import aioesphomeapi +from aioesphomeapi import ButtonInfo, ClimateInfo +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-sensor-push" + + +@pytest.mark.asyncio +async def test_template_climate_sensor_push( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A later change to the backing sensor pushes a new climate state; an unchanged republish does not.""" + clear_host_prefs(DEVICE_NAME) + + state_updates: list[aioesphomeapi.ClimateState] = [] + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + + def on_state(state: aioesphomeapi.EntityState) -> None: + if isinstance(state, aioesphomeapi.ClimateState): + state_updates.append(state) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + publish_temp = require_entity(entities, "publish_temperature", ButtonInfo) + publish_temp_same = require_entity( + entities, "publish_temperature_same", ButtonInfo + ) + publish_humidity = require_entity(entities, "publish_humidity", ButtonInfo) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + initial = initial_state_helper.initial_states.get(test_climate.key) + assert initial is not None, "No initial climate state received" + assert isinstance(initial, aioesphomeapi.ClimateState) + # Neither backing sensor has published anything yet. + assert math.isnan(initial.current_temperature) + assert math.isnan(initial.current_humidity) + + # A later sensor reading -- not the initial one -- pushes a new climate state. + client.button_command(publish_temp.key) + state = await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState) + ) + assert state.current_temperature == pytest.approx(24.0, abs=0.1) + + client.button_command(publish_humidity.key) + state = await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState) + ) + assert state.current_humidity == pytest.approx(65.0, abs=0.1) + + # Re-publishing the same temperature must not cause a redundant climate state update. + updates_before = len(state_updates) + client.button_command(publish_temp_same.key) + await asyncio.sleep(0.3) + assert len(state_updates) == updates_before, ( + "Re-publishing an unchanged sensor reading must not republish the climate state" + ) diff --git a/tests/integration/test_template_climate_set_actions.py b/tests/integration/test_template_climate_set_actions.py new file mode 100644 index 00000000000..0b1eb808740 --- /dev/null +++ b/tests/integration/test_template_climate_set_actions.py @@ -0,0 +1,114 @@ +"""Integration test: each settable field forwards its value to the matching set_*_action. + +With optimistic: false the entity state stays put until climate.template.publish reports the +device's actual state back, so the actions are the only thing that reacts to a command. +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ( + ButtonInfo, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimatePreset, + ClimateSwingMode, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-set-act" + + +@pytest.mark.asyncio +async def test_template_climate_set_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Every set_*_action fires with the requested value; state waits for a publish.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + if "_action " in line or "Unsupported" in line: + log_lines.append(line) + + def logged(fragment: str) -> bool: + return any(fragment in line for line in log_lines) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + test_climate = climate_infos[0] + + report_button = require_entity(entities, "report_device_state", ButtonInfo) + unsupported_button = require_entity( + entities, "report_unsupported_mode", ButtonInfo + ) + + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda state: None) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Both traits are derived from the low/high and humidity set actions, not declared. + assert test_climate.supports_two_point_target_temperature + assert test_climate.supports_target_humidity + + client.climate_command(test_climate.key, mode=ClimateMode.HEAT) + client.climate_command( + test_climate.key, target_temperature_low=18.0, target_temperature_high=24.0 + ) + client.climate_command(test_climate.key, target_humidity=55) + client.climate_command(test_climate.key, fan_mode=ClimateFanMode.LOW) + client.climate_command(test_climate.key, custom_fan_mode="turbo") + client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL) + client.climate_command(test_climate.key, preset=ClimatePreset.ECO) + client.climate_command(test_climate.key, custom_preset="eco_plus") + + for _ in range(50): + await asyncio.sleep(0.1) + if logged("set_custom_preset_action eco_plus"): + break + + assert logged("set_mode_action 3") # CLIMATE_MODE_HEAT + assert logged("set_target_temperature_low_action 18.0") + assert logged("set_target_temperature_high_action 24.0") + assert logged("set_target_humidity_action 55") + assert logged("set_fan_mode_action 3") # CLIMATE_FAN_LOW + assert logged("set_custom_fan_mode_action turbo") + assert logged("set_swing_mode_action 2") # CLIMATE_SWING_VERTICAL + assert logged("set_preset_action 5") # CLIMATE_PRESET_ECO + assert logged("set_custom_preset_action eco_plus") + + # optimistic: false, so none of the commands above touched the entity's own state -- + # a device report is what actually moves it. + client.button_command(report_button.key) + state = await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState) + ) + assert state.mode == ClimateMode.HEAT + + # A publish naming a mode outside supported_modes warns instead of publishing it. + client.button_command(unsupported_button.key) + for _ in range(50): + await asyncio.sleep(0.1) + if logged("Unsupported mode"): + break + assert logged("Unsupported mode") diff --git a/tests/integration/test_template_climate_two_point_temperature.py b/tests/integration/test_template_climate_two_point_temperature.py new file mode 100644 index 00000000000..9270b59ffc1 --- /dev/null +++ b/tests/integration/test_template_climate_two_point_temperature.py @@ -0,0 +1,118 @@ +"""Integration tests for template climate: two-point target temperature + humidity. + +Covers the supports_two_point_target_temperature/supports_target_humidity boolean flags plus +on_control (forwarding commands out) and climate.template.publish (the device reporting its own +authoritative state, independent of any prior command -- e.g. a device that owns its own setpoint, +changed via a physical remote). +""" + +from __future__ import annotations + +import asyncio + +import aioesphomeapi +from aioesphomeapi import ButtonInfo, ClimateInfo, ClimateMode +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "tmpl-clim-two-point" + + +@pytest.mark.asyncio +async def test_template_climate_two_point_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Two-point target temperature + humidity: booleans, on_control, and publish precedence.""" + clear_host_prefs(DEVICE_NAME) + + log_lines: list[str] = [] + + def on_log_line(line: str) -> None: + if "on_control " in line: + log_lines.append(line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + + async def wait_for_climate_state( + timeout: float = 5.0, + ) -> aioesphomeapi.ClimateState: + return await wait_for_state( + client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout + ) + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + + test_climate = climate_infos[0] + assert test_climate.name == "Test Two-Point Heatpump" + assert test_climate.supports_two_point_target_temperature + assert test_climate.supports_target_humidity + + report_button = require_entity(entities, "simulate_device_report", ButtonInfo) + + client.subscribe_states( + initial_state_helper.on_state_wrapper(lambda state: None) + ) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + initial = initial_state_helper.initial_states.get(test_climate.key) + assert initial is not None, "No initial climate state received" + assert isinstance(initial, aioesphomeapi.ClimateState) + # Nothing has been published yet: settable fields have no sensor to seed them from, so + # the entity starts at ESPHome's plain defaults. current_temperature is pushed by the + # referenced sensor, which has already settled by the time we get here. + assert initial.mode == ClimateMode.OFF + assert initial.current_temperature == pytest.approx(21.0, abs=0.1) + + # The device reports its actual state for the first time. + client.button_command(report_button.key) + state = await wait_for_climate_state() + assert state.mode == ClimateMode.HEAT_COOL + assert state.target_temperature_low == pytest.approx(18.0, abs=0.1) + assert state.target_temperature_high == pytest.approx(24.0, abs=0.1) + assert state.target_humidity == pytest.approx(50.0, abs=0.1) + + # Commands apply optimistically (settable fields are plain internal state), and on_control + # fires with the same values so a real config could forward them to the device. + client.climate_command( + test_climate.key, target_temperature_low=19.0, target_temperature_high=25.0 + ) + state = await wait_for_climate_state() + assert state.target_temperature_low == pytest.approx(19.0, abs=0.1) + assert state.target_temperature_high == pytest.approx(25.0, abs=0.1) + await asyncio.sleep(0.2) + assert any( + "on_control target_temperature_low=19.0" in line for line in log_lines + ) + assert any( + "on_control target_temperature_high=25.0" in line for line in log_lines + ) + + client.climate_command(test_climate.key, target_humidity=45.0) + state = await wait_for_climate_state() + assert state.target_humidity == pytest.approx(45.0, abs=0.1) + await asyncio.sleep(0.2) + assert any("on_control target_humidity=45.0" in line for line in log_lines) + + # The device's next report is authoritative and overrides whatever was optimistically + # applied above -- this is the whole point of climate.template.publish: a device that owns + # its own state (e.g. changed by a physical remote) always wins. + client.button_command(report_button.key) + state = await wait_for_climate_state() + assert state.target_temperature_low == pytest.approx(18.0, abs=0.1) + assert state.target_temperature_high == pytest.approx(24.0, abs=0.1) + assert state.target_humidity == pytest.approx(50.0, abs=0.1) From 95ab3fb4f29aed85f762f2699484fea9e756b4f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Sep 2026 23:59:40 +0200 Subject: [PATCH 014/759] [ota] Offer encryption with the api key so enabling it works over OTA (#18979) --- THREAT_MODEL.md | 55 ++- esphome/__main__.py | 14 +- esphome/components/api/__init__.py | 4 +- esphome/components/api/api_connection.cpp | 6 +- .../components/api/api_frame_helper_noise.cpp | 2 +- esphome/components/api/api_server.cpp | 37 +- esphome/components/api/api_server.h | 12 +- esphome/components/esphome/ota/__init__.py | 130 +++--- .../components/esphome/ota/ota_esphome.cpp | 83 ++-- esphome/components/esphome/ota/ota_esphome.h | 13 +- .../esphome/ota/ota_esphome_noise.cpp | 91 +++-- esphome/components/noise/__init__.py | 36 +- esphome/components/noise/noise.cpp | 9 + esphome/components/noise/noise.h | 16 +- esphome/components/noise/noise_handshake.cpp | 5 +- esphome/components/noise/noise_handshake.h | 6 +- esphome/core/defines.h | 3 + esphome/espota2.py | 122 +++++- esphome/wizard.py | 18 +- .../noise/test_encryption_key.py | 14 +- tests/component_tests/ota/test_esphome_ota.py | 242 +++++++++--- .../ota/test_esphome_ota_api_key_offer.yaml | 11 + ...st_esphome_ota_api_key_offer_password.yaml | 12 + .../test_esphome_ota_encryption_required.yaml | 12 + .../ota/test_esphome_ota_own_key.yaml | 11 + .../ota/test_esphome_ota_plain.yaml | 9 + .../ota/test_esphome_ota_runtime_api_key.yaml | 10 + .../components/noise/test_noise_handshake.cpp | 18 +- .../noise/test_noise_primitives.cpp | 13 +- tests/components/ota/api_key_offer.yaml | 12 + tests/components/ota/api_runtime_key.yaml | 10 + .../ota/test-api_key_offer.esp32-idf.yaml | 2 + .../ota/test-api_key_offer.esp8266-ard.yaml | 2 + .../ota/test-api_runtime_key.esp32-idf.yaml | 2 + .../ota/test-api_runtime_key.esp8266-ard.yaml | 2 + tests/integration/conftest.py | 7 + tests/integration/const.py | 7 + .../host_ota_api_key_offer_with_password.yaml | 12 + .../host_ota_provisioned_api_key.yaml | 10 + .../test_api_zero_psk_provisioning.py | 51 ++- tests/integration/test_host_ota.py | 373 ++++++++++++------ tests/unit_tests/test_espota2_noise.py | 136 ++++++- tests/unit_tests/test_main.py | 114 +++++- tests/unit_tests/test_wizard.py | 31 +- 44 files changed, 1342 insertions(+), 443 deletions(-) create mode 100644 tests/component_tests/ota/test_esphome_ota_api_key_offer.yaml create mode 100644 tests/component_tests/ota/test_esphome_ota_api_key_offer_password.yaml create mode 100644 tests/component_tests/ota/test_esphome_ota_encryption_required.yaml create mode 100644 tests/component_tests/ota/test_esphome_ota_own_key.yaml create mode 100644 tests/component_tests/ota/test_esphome_ota_plain.yaml create mode 100644 tests/component_tests/ota/test_esphome_ota_runtime_api_key.yaml create mode 100644 tests/components/ota/api_key_offer.yaml create mode 100644 tests/components/ota/api_runtime_key.yaml create mode 100644 tests/components/ota/test-api_key_offer.esp32-idf.yaml create mode 100644 tests/components/ota/test-api_key_offer.esp8266-ard.yaml create mode 100644 tests/components/ota/test-api_runtime_key.esp32-idf.yaml create mode 100644 tests/components/ota/test-api_runtime_key.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/host_ota_api_key_offer_with_password.yaml create mode 100644 tests/integration/fixtures/host_ota_provisioned_api_key.yaml diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index b4f557e55b9..11656ff0b72 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -125,30 +125,47 @@ design is optimal or that it will not change. ## OTA update encryption The `esphome` OTA platform optionally encrypts updates with the same Noise -`NNpsk0` pattern the native API uses; one key protects the device. With an -`encryption:` block configured the guarantees are: the firmware image is -confidential in transit, the uploader is authenticated by the pre-shared key, -and the plaintext negotiation preceding the handshake is bound into the -handshake prologue, so stripping or tampering with it fails the first MAC. -Both ends fail closed with no override: a device built with a key refuses +`NNpsk0` pattern the native API uses; one key protects the device. A device +whose `api:` block has an encryption key, static in the YAML or provisioned at +runtime, compiles in the transport and offers it on every OTA connection once +it holds a key, so an uploader presenting that key gets the guarantees below +even without an `ota: encryption:` block; only that block makes the device +require encryption. The guarantees are: the firmware image is confidential in +transit, the uploader is authenticated by the pre-shared key, and the plaintext +negotiation preceding the handshake is bound into the handshake prologue, so +stripping or tampering with it fails the first MAC. With `ota: encryption:` +configured both ends fail closed with no override: the device refuses plaintext uploads, and the CLI refuses to send plaintext when a key is -configured. +configured. Without that block the CLI tries a static api key when the device +offers and, until 2027.3.0, falls back to plaintext with a warning when the +offer is missing or the handshake fails; a runtime provisioned key never +reaches the CLI, so those uploads stay plaintext. -Defeating any of that without the key is in scope: a keyed device accepting a -plaintext or downgraded upload, getting past the MAC, or recovering image -contents from captured traffic. +Defeating any of that without the key is in scope: a device that requires +encryption accepting a plaintext or downgraded upload, getting past the MAC, +or recovering image contents from captured traffic. The following are **not** vulnerabilities, by design: -- Plaintext OTA on a device with no `encryption:` block. That is the - documented default, authenticated (if at all) by the OTA password. -- The enablement window: turning encryption on takes one last upload of the - encryption-enabled firmware over the existing plaintext channel, with the - pre-existing plaintext exposure. -- The web OTA `/update` endpoint alongside encryption. The `web_server` - component keeps it always reachable, and `captive_portal:` auto-loads it - for the fallback AP window; validation warns about both combinations, and - the operator keeps the recovery path. +- Plaintext OTA on a device with no `ota: encryption:` block, including one + that offers encryption because it has an api key. That is the documented + default, authenticated (if at all) by the OTA password. An uploader that + takes the offer skips the password; the key authenticates it. With a + runtime provisioned key and no `provisioning:` window, whoever provisions + the key gains that upload path too; validation warns about the pair. +- The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an + active attacker who strips the offer or breaks the handshake can make a + keyed CLI upload plaintext, with the pre-existing plaintext exposure. A + device that requires encryption still refuses that upload. +- The enablement window: firmware built with a static api key already offers + encryption, so turning on `ota: encryption:` is itself an encrypted upload. + Older firmware needs one last plaintext upload of an offering build, with + the pre-existing plaintext exposure. +- The web OTA `/update` endpoint alongside encryption. With the `web_server` + or `prometheus` component the shared listener is always up, so the endpoint + stays reachable and validation warns about that combination; + `captive_portal:` alone brings the listener up only for the fallback AP + window, which is the intended recovery path, so that is not warned about. - CLI retry behavior on transport or MAC failures; every attempt renegotiates a fresh handshake with fresh ephemerals, so retrying does not weaken authentication. diff --git a/esphome/__main__.py b/esphome/__main__.py index b3d58ad13bc..30e97f55eb4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1335,12 +1335,14 @@ def _upload_via_native_api( break from esphome import espota2 + from esphome.components.noise import static_encryption_key remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) # Fail closed: an encryption block whose key did not resolve must never # fall back to a plaintext upload noise_psk = None + plaintext_fallback = False if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) if not noise_psk: @@ -1351,6 +1353,10 @@ def _upload_via_native_api( # Ensure the key is a string, as required by the underlying OTA implementation. # It arrives here as a SensitiveStr which aioesphomeapi rejects. noise_psk = str(noise_psk) + elif api_key := static_encryption_key(config.get(CONF_API) or {}): + # Remove before 2027.3.0: the api key is tried, falling back to plaintext + noise_psk = str(api_key) + plaintext_fallback = True def check_partition_access(option_string: str) -> None: if not ota_conf.get("allow_partition_access"): @@ -1382,7 +1388,13 @@ def _upload_via_native_api( _validate_bootloader_binary(binary) return espota2.run_ota( - network_devices, remote_port, password, binary, ota_type, noise_psk + network_devices, + remote_port, + password, + binary, + ota_type, + noise_psk, + plaintext_fallback=plaintext_fallback, ) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3568318dadf..6202e127bfc 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -14,6 +14,7 @@ from esphome.components.noise import ( # noqa: F401 ENCRYPTION_SCHEMA, decode_encryption_key, encryption_schema, + new_psk_progmem, validate_encryption_key, ) from esphome.config_helpers import filter_source_files_from_defines, get_logger_level @@ -589,8 +590,7 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - decoded = decode_encryption_key(key) - cg.add(var.set_noise_psk(list(decoded))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9c609aa0470..da4b7d7702f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2161,7 +2161,10 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) { NoiseEncryptionSetKeyResponse resp; resp.success = false; - +#ifdef USE_API_NOISE_PSK_FROM_YAML + // A yaml key cannot be changed at runtime, so no decode or save path is built + ESP_LOGW(TAG, "Key set in YAML"); +#else #ifdef USE_PROVISIONING // Refuse to set a key once the provisioning window has closed (defense in depth; // such connections are already rejected at hello). @@ -2196,6 +2199,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } #endif } +#endif // USE_API_NOISE_PSK_FROM_YAML return this->send_message(resp); } diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 138dbdddbaf..29b2858aee8 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -548,7 +548,7 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { * @return 0 on success, -1 on error (check errno) */ APIError APINoiseFrameHelper::init_handshake_() { - int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size()); + int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size()); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 43d35363d3c..78ebe5c38e9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -41,13 +41,13 @@ void APIServer::setup() { ControllerRegistry::register_controller(this); #ifdef USE_API_NOISE + // Always reserve the slot: flash preferences are positional on esp8266, so + // a yaml key build must keep the layout of a runtime key build uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); - #ifndef USE_API_NOISE_PSK_FROM_YAML - // Only load saved PSK if not set from YAML - if (this->load_and_apply_noise_psk_()) { + // A cleared record loads fine but holds no key + if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { ESP_LOGD(TAG, "Loaded saved Noise PSK"); } #endif @@ -550,6 +550,7 @@ const std::vector &APIServer::get_sta #endif #ifdef USE_API_NOISE +#ifndef USE_API_NOISE_PSK_FROM_YAML bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { if (!this->noise_pref_.save(&new_psk)) { @@ -583,22 +584,19 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString } bool APIServer::load_and_apply_noise_psk_() { - SavedNoisePsk saved{}; - if (!this->noise_pref_.load(&saved)) + // Load into a temp so a failed read cannot disturb the key in use + SavedNoisePsk loaded{}; + if (!this->noise_pref_.load(&loaded)) return false; - this->set_noise_psk(saved.psk); + this->saved_psk_ = loaded; + // An unprovisioned device stores the reserved all-zeros key, which is no key + const bool has_key = !noise::NoiseContext::is_all_zeros(this->saved_psk_.psk); + this->noise_ctx_.set_psk(has_key ? this->saved_psk_.psk.data() : nullptr); return true; } bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { -#ifdef USE_API_NOISE_PSK_FROM_YAML - // When PSK is set from YAML, this function should never be called - // but if it is, reject the change - ESP_LOGW(TAG, "Key set in YAML"); - return false; -#else - auto &old_psk = this->noise_ctx_.get_psk(); - if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { + if (this->saved_psk_.psk == psk) { ESP_LOGW(TAG, "New PSK matches old"); return true; } @@ -614,15 +612,8 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { } #endif return result; -#endif } bool APIServer::clear_noise_psk(bool make_active) { -#ifdef USE_API_NOISE_PSK_FROM_YAML - // When PSK is set from YAML, this function should never be called - // but if it is, reject the change - ESP_LOGW(TAG, "Key set in YAML"); - return false; -#else SavedNoisePsk empty_psk{}; bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), make_active); @@ -634,8 +625,8 @@ bool APIServer::clear_noise_psk(bool make_active) { } #endif return result; -#endif } +#endif // USE_API_NOISE_PSK_FROM_YAML #endif #ifdef USE_HOMEASSISTANT_TIME diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 072a5839016..618ea4eb11a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -76,9 +76,14 @@ class APIServer final : public Component, APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } #ifdef USE_API_NOISE +#ifndef USE_API_NOISE_PSK_FROM_YAML + // Runtime key changes exist for the provisioning path only (not lambdas); + // with a yaml key they compile out bool save_noise_psk(noise::psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); } +#endif + /// psk points at 32 bytes that live in flash for the life of the program + void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE @@ -275,10 +280,12 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE +#ifndef USE_API_NOISE_PSK_FROM_YAML bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); // Load saved PSK from preferences and apply it. Returns true on success. bool load_and_apply_noise_psk_(); +#endif // USE_API_NOISE_PSK_FROM_YAML #endif // USE_API_NOISE #ifdef USE_API_HOMEASSISTANT_STATES // Helper methods to reduce code duplication @@ -358,6 +365,9 @@ class APIServer final : public Component, #ifdef USE_API_NOISE noise::NoiseContext noise_ctx_; +#ifndef USE_API_NOISE_PSK_FROM_YAML + SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key +#endif ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 1fec9e5c9b4..f5eb878260c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,12 +2,12 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - decode_encryption_key, encryption_schema, - is_reserved_key, + new_psk_progmem, + static_encryption_key, ) from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code -from esphome.config_helpers import merge_config +from esphome.config_helpers import filter_source_files_from_defines, merge_config import esphome.config_validation as cv from esphome.const import ( CONF_API, @@ -31,7 +31,6 @@ import esphome.final_validate as fv from esphome.types import ConfigType CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" -CONF_CAPTIVE_PORTAL = "captive_portal" _LOGGER = logging.getLogger(__name__) @@ -41,11 +40,10 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(config: ConfigType) -> list[str]: - """Auto-load noise only when encryption is configured.""" + """Auto-load noise only when encryption is configured; the api key offer + inherits it from the api component.""" base = ["sha256", "socket"] - # A falsy config is a tooling probe for the maximal set (None from - # dependency resolution, {} from the components-graph platform probe); - # a validated config always carries defaults, never empty + # A falsy config is a tooling probe for the maximal set if not config or CONF_ENCRYPTION in config: return base + ["noise"] return base @@ -132,12 +130,56 @@ def ota_esphome_final_validate(config: ConfigType) -> None: _validate_no_password_with_encryption(ota_conf) if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: _resolve_encryption_key(encryption_conf, api_conf) - if any( - conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf - ) and any( - CONF_ENCRYPTION in conf for conf in merged_ota_esphome_configs_by_port.values() + elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None: + _LOGGER.warning( + "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " + "bytes plus the password on the heap): the device already offers " + "encryption with the '%s' %s %s, which authenticates any uploader " + "that takes it, and a password only matters for uploaders without " + "encryption support; remove '%s' and add '%s' under '%s' so " + "uploads use the key and encryption is required", + CONF_OTA, + CONF_PASSWORD, + CONF_API, + CONF_ENCRYPTION, + CONF_KEY, + CONF_PASSWORD, + CONF_ENCRYPTION, + CONF_OTA, + ) + elif ( + CONF_PASSWORD in ota_conf + and CONF_ENCRYPTION in api_conf + and not api_conf[CONF_ENCRYPTION].get(CONF_KEY) + ): + # The CLI still needs the password; whoever provisions the key skips it + _LOGGER.warning( + "The '%s' %s %s provisioned at runtime also authenticates OTA " + "uploads once provisioned; '%s' %s then only guards plaintext " + "uploads. Whoever provisions the key can upload firmware " + "without the password, so add a 'provisioning:' block to limit " + "when that is possible", + CONF_API, + CONF_ENCRYPTION, + CONF_KEY, + CONF_OTA, + CONF_PASSWORD, + ) + # web_server and prometheus keep the shared listener up; the captive + # portal's copy only exists on the fallback AP and is the recovery path + if ( + (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) + and any( + CONF_ENCRYPTION in conf + for conf in merged_ota_esphome_configs_by_port.values() + ) ): - _warn_web_server_ota(full_conf) + _LOGGER.warning( + "OTA encryption does not cover the %s OTA platform; its " + "plaintext /update endpoint accepts the same image", + CONF_WEB_SERVER, + ) full_conf[CONF_OTA] = new_ota_conf fv.full_config.set(full_conf) @@ -152,33 +194,11 @@ def ota_esphome_final_validate(config: ConfigType) -> None: ) -def _warn_web_server_ota(full_conf: ConfigType) -> None: - """The web_server ota platform accepts the same image over plaintext HTTP - with basic auth, bypassing the encryption; warn rather than fail so the - operator keeps the recovery path.""" - if CONF_CAPTIVE_PORTAL in full_conf and CONF_WEB_SERVER not in full_conf: - # The captive_portal auto-load: the endpoint only exists while the - # fallback AP is active - _LOGGER.warning( - "OTA encryption does not cover the %s OTA platform (auto-loaded " - "by captive_portal); the plaintext /update endpoint stays " - "reachable while the fallback AP is active", - CONF_WEB_SERVER, - ) - else: - _LOGGER.warning( - "OTA encryption does not cover the %s OTA platform; its " - "plaintext /update endpoint accepts the same image", - CONF_WEB_SERVER, - ) - - def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None: """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited, and the all-zeros - provisioning sentinel is rejected (the device treats it as no key). + a runtime provisioned api key cannot be inherited. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -201,11 +221,6 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key - if is_reserved_key(encryption_conf[CONF_KEY]): - raise cv.Invalid( - f"The all-zeros {CONF_KEY} is reserved and provides no protection; " - f"generate a real key with: openssl rand -base64 32" - ) # Also called on merged same-port configs in final validate, where schemas @@ -267,15 +282,9 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate -def FILTER_SOURCE_FILES() -> list[str]: - """Filter out the noise transport when no ota entry configures encryption.""" - for ota_conf in CORE.config.get(CONF_OTA, []): - if ( - ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME - and ota_conf.get(CONF_ENCRYPTION) is not None - ): - return [] - return ["ota_esphome_noise.cpp"] +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"} +) @coroutine_with_priority(CoroPriority.OTA_UPDATES) @@ -296,11 +305,24 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") - if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None: - # A missing key was resolved from the api component in final validate. - key = encryption_conf[CONF_KEY] + # One key per device: an api encryption block supplies it (static or + # runtime) and offers; the ota block only adds the requirement + api_conf = CORE.config.get(CONF_API) or {} + encryption_conf = config.get(CONF_ENCRYPTION) + own_key = None + if encryption_conf is not None and static_encryption_key(api_conf) is None: + own_key = encryption_conf[CONF_KEY] + if own_key is not None: cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(list(decode_encryption_key(key)))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + elif CONF_ENCRYPTION in api_conf: + cg.add_define("USE_OTA_ENCRYPTION") + cg.add_define("USE_OTA_ENCRYPTION_FROM_API") + if static_encryption_key(api_conf) is None: + # The key arrives at runtime, so the offer has to look for it + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 396a47bc52c..1005ed214b6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,4 +1,7 @@ #include "ota_esphome.h" +#ifdef USE_OTA_ENCRYPTION_FROM_API +#include "esphome/components/api/api_server.h" +#endif #ifdef USE_OTA #ifdef USE_OTA_PASSWORD #include "esphome/components/sha256/sha256.h" @@ -26,6 +29,16 @@ namespace esphome { static const char *const TAG = "esphome.ota"; + +#ifdef USE_OTA_ENCRYPTION +const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { +#ifdef USE_OTA_ENCRYPTION_FROM_API + return api::global_api_server->get_noise_ctx(); +#else + return this->noise_ctx_; +#endif +} +#endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer @@ -97,18 +110,30 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" - " Version: %d", - network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); + " Version: %d" +#ifdef USE_OTA_ENCRYPTION + "\n Encryption: %s" +#endif + , + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION +#ifdef USE_OTA_ENCRYPTION_REQUIRED + , + LOG_STR_LITERAL("required") +#elif defined(USE_OTA_ENCRYPTION_PROVISIONED) + // A runtime provisioned key may not exist yet + , + this->noise_context_().has_psk() ? LOG_STR_LITERAL("offered, plaintext accepted") + : LOG_STR_LITERAL("offered once the api key is provisioned") +#elif defined(USE_OTA_ENCRYPTION) + , + LOG_STR_LITERAL("offered, plaintext accepted") +#endif + ); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); } #endif -#ifdef USE_OTA_ENCRYPTION - if (this->noise_ctx_.has_psk()) { - ESP_LOGCONFIG(TAG, " Encryption configured"); - } -#endif #ifdef USE_OTA_PARTITIONS ESP_LOGCONFIG(TAG, " Partition access allowed\n" @@ -154,10 +179,22 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; +// Noise needs the extended protocol: the prologue binds the 2-byte feature ack +static constexpr uint8_t CLIENT_NOISE_FEATURES = + CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; +inline bool ESPHomeOTAComponent::extended_proto_() const { +#ifdef USE_OTA_ENCRYPTION_REQUIRED + // FEATURE_READ already refused every client without the extended protocol + return true; +#else + return (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; +#endif +} + void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. /// @@ -241,12 +278,9 @@ void ESPHomeOTAComponent::handle_handshake_() { this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); -#ifdef USE_OTA_ENCRYPTION - // Fail closed: with a PSK configured the client must negotiate encryption - // (which requires the extended protocol); refuse plaintext uploads. - static constexpr uint8_t NOISE_REQUIRED_FEATURES = - CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; - if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) { +#ifdef USE_OTA_ENCRYPTION_REQUIRED + // `ota: encryption:` requires the client to negotiate encryption + if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) { ESP_LOGW(TAG, "Client does not support encryption"); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED); return; @@ -261,18 +295,21 @@ void ESPHomeOTAComponent::handle_handshake_() { // Compose the feature-ack response. When the client negotiates the extended protocol we emit // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte // legacy response. - this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; - if (this->extended_proto_) { + if (this->extended_proto_()) { static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); #ifdef USE_OTA_PARTITIONS this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; #endif -#ifdef USE_OTA_ENCRYPTION - if (this->noise_ctx_.has_psk()) { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // A runtime provisioned key may not exist yet + if (this->noise_context_().has_psk()) { this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; } +#elif defined(USE_OTA_ENCRYPTION) + // A yaml key always exists: validation rejects the all-zeros key + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; #endif } else { this->handshake_buf_[0] = @@ -284,15 +321,15 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::FEATURE_ACK: { static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; - const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; + const size_t ack_size = this->extended_proto_() ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } #ifdef USE_OTA_ENCRYPTION - // With a PSK configured the rest of the session runs inside the noise - // transport; the client sends the first handshake frame next, so there - // is nothing to do until data arrives. - if (this->noise_ctx_.has_psk()) { + // Latch the offer actually sent: a key activating between the two + // states must not start a session the client never expects + if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && + (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) { // handshake_buf_ still holds the feature ack composed above; a // would-block re-entry lands here without rebuilding it if (!this->noise_start_session_(this->handshake_buf_[1])) { @@ -412,7 +449,7 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge auth OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); - if (this->extended_proto_) { + if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { this->log_read_error_(LOG_STR("OTA type")); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index fd164b81381..c6f710b3fcb 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,8 +44,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#ifdef USE_OTA_ENCRYPTION - void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); } +#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) + /// psk points at 32 bytes that live in flash for the life of the program + void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif /// Manually set the port OTA should listen on @@ -85,9 +86,12 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; }; + // The api server's live context when the api has encryption, else our own + const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); bool noise_try_read_frame_(); + size_t noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len); bool noise_try_write_frame_(); void noise_send_reject_(const LogString *reason); ssize_t noise_decrypt_(uint8_t *buf, size_t len); @@ -144,7 +148,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION +#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#endif std::unique_ptr noise_; #endif // USE_OTA_ENCRYPTION @@ -166,6 +172,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { "OTA_BUFFER_SIZE must fit a full encrypted data frame"); #endif static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + // Derived from the feature byte; storing it would pad the trailing bytes + bool extended_proto_() const; #ifdef USE_OTA_PARTITIONS uint32_t running_app_offset_{0}; size_t running_app_size_{0}; @@ -179,7 +187,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD - bool extended_proto_{false}; }; } // namespace esphome diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7f8331cf961..7401413d6d0 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -3,6 +3,7 @@ #ifdef USE_OTA_ENCRYPTION #include "esphome/components/noise/noise.h" #include "esphome/components/ota/ota_backend.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -40,24 +41,17 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { * "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags */ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { + // A provisioned key cleared between the offer and here is not guarded: the + // session runs on the zero key load_psk fills in and fails the client's MAC. + // Default-init: the frame buffer is written before it is read // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession()); - if (this->noise_ == nullptr) { - ESP_LOGW(TAG, "Session allocation failed"); - this->cleanup_connection_(); - return false; - } - + this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN + PROLOGUE_FEATURE_ACK_LEN]; -#ifdef USE_ESP8266 - memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); -#else - std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); -#endif + progmem_memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN; // Magic bytes, already validated in MAGIC_READ std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES)); @@ -71,9 +65,13 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; *p++ = server_feature_flags; - int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue)); + // The caller only starts a session when the context holds a key + int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY + : this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue)); if (err != 0) { - ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + // Raw noise codes throughout: the name table would cost flash in builds + // where only the OTA uses noise + ESP_LOGW(TAG, "Session init: %d", err); this->cleanup_connection_(); return false; } @@ -105,14 +103,16 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { s.frame_pos = 0; s.frame_len = 0; if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) { - ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]); + ESP_LOGW(TAG, "Client rejected the handshake: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]); this->cleanup_connection_(); return false; } int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1); if (err != 0) { - ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); - this->noise_send_reject_(noise::reject_reason_for(err)); + // A MAC failure here almost always means the uploader has a different key + const LogString *reason = noise::reject_reason_for(err); + ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err); + this->noise_send_reject_(reason); this->cleanup_connection_(); return false; } @@ -123,7 +123,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { int err = s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len); if (err != 0) { - ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + ESP_LOGW(TAG, "Handshake write: %d", err); this->cleanup_connection_(); return false; } @@ -138,7 +138,7 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: { int err = s.handshake.split(s.send_cipher, s.recv_cipher); if (err != 0) { - ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + ESP_LOGW(TAG, "Handshake split: %d", err); this->cleanup_connection_(); return false; } @@ -154,33 +154,41 @@ bool ESPHomeOTAComponent::handle_noise_handshake_() { } } +/// Payload length from a frame header, or 0 (logged) when the indicator or +/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid. +size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) { + const size_t payload_len = encode_uint16(header[1], header[2]); + if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) { + ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], payload_len); + return 0; + } + return payload_len; +} + /// Non-blocking read of one handshake frame into the session buffer. bool ESPHomeOTAComponent::noise_try_read_frame_() { NoiseSession &s = *this->noise_; - while (s.frame_pos < noise::FRAME_HEADER_SIZE) { - ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos); - if (!this->handle_read_error_(read, LOG_STR("read noise header"))) { - return false; + while (true) { + // The header first, then the body once the header says how long it is + const uint16_t want = s.frame_len == 0 ? noise::FRAME_HEADER_SIZE : s.frame_len; + if (s.frame_pos < want) { + ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, want - s.frame_pos); + if (!this->handle_read_error_(read, LOG_STR("read noise"))) { + return false; + } + s.frame_pos += read; + continue; } - s.frame_pos += read; - } - if (s.frame_len == 0) { - const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]); - if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) { - ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len); + if (s.frame_len != 0) { + return true; + } + const size_t payload_len = this->noise_frame_payload_len_(s.frame_buf, 1, 1 + noise::MAX_HANDSHAKE_SIZE); + if (payload_len == 0) { this->cleanup_connection_(); return false; } s.frame_len = noise::FRAME_HEADER_SIZE + payload_len; } - while (s.frame_pos < s.frame_len) { - ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos); - if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) { - return false; - } - s.frame_pos += read; - } - return true; } /// Non-blocking write of the pending session-buffer frame. @@ -214,7 +222,7 @@ ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) { noise_buffer_set_inout(mbuf, buf, len, len); int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf); if (err != 0) { - ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + ESP_LOGW(TAG, "Decrypt: %d", err); return -1; } return mbuf.size; @@ -229,9 +237,8 @@ ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min if (!this->readall_(header, sizeof(header))) { return -1; } - const size_t ciphertext_len = encode_uint16(header[1], header[2]); - if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) { - ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len); + const size_t ciphertext_len = this->noise_frame_payload_len_(header, min_ciphertext, max_ciphertext); + if (ciphertext_len == 0) { return -1; } if (!this->readall_(buf, ciphertext_len)) { @@ -267,7 +274,7 @@ bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) { noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE); int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf); if (err != 0) { - ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + ESP_LOGW(TAG, "Encrypt: %d", err); return false; } noise::write_frame_header(frame, mbuf.size); diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 0f9328a482a..a1d9444fc00 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -4,7 +4,9 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_KEY +from esphome.const import CONF_ENCRYPTION, CONF_KEY +from esphome.core import ID +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -23,6 +25,14 @@ def validate_encryption_key(value: Any) -> str: if len(decoded) != 32: raise cv.Invalid("Encryption key must be base64 and 32 bytes long") + if not any(decoded): + # The device treats the all-zeros key as no key at all (it is the + # provisioning sentinel), so it must never reach a build + raise cv.Invalid( + f"The all-zeros {CONF_KEY} is reserved and provides no protection; " + f"omit the {CONF_KEY} to provision it at runtime, or generate a real " + "key with: openssl rand -base64 32" + ) # Return original data for roundtrip conversion return value @@ -45,15 +55,6 @@ def decode_encryption_key(value: str) -> bytes: return decoded -def is_reserved_key(value: str) -> bool: - """Whether the key is the reserved all-zeros provisioning sentinel. - - The device treats it as no key configured, so consumers that require a - real key must reject it. - """ - return not any(decode_encryption_key(value)) - - ENCRYPTION_SCHEMA = cv.Schema( { cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), @@ -61,6 +62,21 @@ ENCRYPTION_SCHEMA = cv.Schema( ) +def static_encryption_key(conf: ConfigType) -> str | None: + """The build time key of a component config; None without one or when + the key is provisioned at runtime.""" + return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None + + +def new_psk_progmem(parent_id: ID, key: str) -> MockObj: + """Emit the decoded key as a PROGMEM array; the component keeps a pointer + so the key never occupies RAM.""" + return cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decode_encryption_key(key)), + ) + + def encryption_schema(config: ConfigType | None) -> ConfigType: # A bare `encryption:` block is valid; a missing key means the consumer # falls back to its keyless behavior (api provisioning, ota inheriting diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 95fab322dbd..48067061673 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -1,5 +1,6 @@ #include "noise.h" #ifdef USE_NOISE +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -15,6 +16,14 @@ namespace esphome::noise { static const char *const TAG = "noise"; +void NoiseContext::load_psk(psk_t &out) const { + if (this->psk_ == nullptr) { + out.fill(0); + return; + } + progmem_memcpy(out.data(), this->psk_, out.size()); +} + const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) return LOG_STR("NO_MEMORY"); diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h index f9da8d35b82..1033d5423ce 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -23,16 +23,16 @@ class NoiseContext { } return acc == 0; } - void set_psk(psk_t psk) { - this->psk_ = psk; - this->has_psk_ = !is_all_zeros(psk); - } - const psk_t &get_psk() const { return this->psk_; } - bool has_psk() const { return this->has_psk_; } + /// psk points at 32 bytes that outlive the context (PROGMEM or caller owned + /// RAM); nullptr means no key. Runtime callers map the all-zeros key to + /// nullptr themselves; validation keeps it out of yaml. + void set_psk(const uint8_t *psk) { this->psk_ = psk; } + /// Copy the key out (flash-aware on ESP8266); all zeros when none is set. + void load_psk(psk_t &out) const; + bool has_psk() const { return this->psk_ != nullptr; } protected: - psk_t psk_{}; - bool has_psk_{false}; + const uint8_t *psk_{nullptr}; }; /// Convert a noise error code to a readable error diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 6d426de012e..cc7fa603c4e 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() { } } -int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { +int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) { if (this->handshake_ != nullptr) { noise_handshakestate_free(this->handshake_); this->handshake_ = nullptr; @@ -44,6 +44,9 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err); return err; } + // noise-c keeps its own copy, so the key only passes through the stack here + psk_t psk; + ctx.load_psk(psk); err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size()); if (err != 0) { HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err); diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index 30596f35c20..bf1aa8cb7fb 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -36,9 +36,9 @@ class NoiseResponderHandshake { NoiseResponderHandshake(const NoiseResponderHandshake &) = delete; NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete; - /// Create and start the handshake with the given PSK and prologue. A - /// repeated call frees the previous handshake state and starts over. - [[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len); + /// Create and start the handshake with the context's PSK and the prologue. + /// A repeated call frees the previous handshake state and starts over. + [[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len); /// ACTION_FAILED is the catch-all: returned before init(), after split() /// has released the state, and when noise-c reports a failed handshake. [[nodiscard]] Action action() const; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 526adf74f01..9dd1e0ced64 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -244,6 +244,9 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION +#define USE_OTA_ENCRYPTION_FROM_API +#define USE_OTA_ENCRYPTION_PROVISIONED +#define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE diff --git a/esphome/espota2.py b/esphome/espota2.py index ac4cbeeb7c5..ce403c398db 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -202,6 +202,49 @@ class OTANetworkError(OTAError): """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" +# Remove before 2027.3.0 +class OTAEncryptionFallback(OTAError): + """The encrypted attempt failed and the caller may retry in plaintext.""" + + +# Remove before 2027.3.0 +PLAINTEXT_FALLBACK_NOTICE = ( + "A device with an api encryption key offers encryption after this " + "install; add 'encryption:' under 'ota: platform: esphome' to require it. " + "This plaintext fallback is removed in 2027.3.0." +) + + +# Remove before 2027.3.0 +class _EncryptionAttempt: + """The key an upload tries and whether it may fall back to plaintext; + a rejected handshake falls back at once, a transport fault only on repeat.""" + + def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None: + self.noise_psk = noise_psk + self.plaintext_fallback = plaintext_fallback + self.handshake_faults = 0 + + def handshake_fault_falls_back(self) -> bool: + self.handshake_faults += 1 + return self.plaintext_fallback and self.handshake_faults >= 2 + + def downgrade(self, reason: str) -> None: + _LOGGER.warning( + "%s. Retrying in plaintext; a device that requires encryption " + "refuses it. %s", + reason, + PLAINTEXT_FALLBACK_NOTICE, + ) + self.noise_psk = None + self.plaintext_fallback = False + + +# Remove before 2027.3.0: only the fallback decision needs this distinction +class OTAHandshakeNetworkError(OTANetworkError): + """A transport failure inside the noise handshake; retrying encrypted may succeed.""" + + def _committed_error(err: OTANetworkError) -> OTAError: """Wrap a network failure that happened once the device had the full image. @@ -464,6 +507,7 @@ def perform_ota( filename: Path, ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, + plaintext_fallback: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -528,19 +572,28 @@ def perform_ota( else: features = 0 - if noise_psk: - # Fail closed: never fall back to a plaintext upload when an - # encryption key is configured, an active attacker could otherwise - # strip the feature flag and capture the image (it contains the wifi - # credentials and the api encryption key). - if not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): + if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): + if plaintext_fallback: + # Remove before 2027.3.0: older firmware that cannot encrypt still + # gets its update on this connection + _LOGGER.warning( + "The device did not offer OTA encryption; continuing in plaintext. %s", + PLAINTEXT_FALLBACK_NOTICE, + ) + noise_psk = None + else: + # Fail closed: an attacker could otherwise strip the offer and + # capture the image (wifi credentials, api key) raise OTAError( "An OTA encryption key is configured but the device did not " "offer encryption; refusing to send the image in plaintext. " - "If the running firmware predates OTA encryption, first update " - "it without the 'ota: encryption:' block (over a trusted " - "network or via USB), then restore the block and upload again." + "The running firmware predates ESPHome 2026.9.0 or has no " + "'api: encryption: key'. With an api key, install once " + "without the 'ota: encryption:' block (that build offers " + "encryption), then restore it; otherwise flash by serial or " + "the web_server OTA platform." ) + if noise_psk: # The prologue binds every negotiation byte both sides saw, so any # tampering with the plaintext preamble breaks the handshake. prologue = ( @@ -549,8 +602,18 @@ def perform_ota( + bytes([RESPONSE_OK, version, features_to_send]) + bytes([RESPONSE_FEATURE_FLAGS, features]) ) + # Built outside the try: a local failure must never downgrade the upload sock = NoiseSocketWrapper(sock, noise_psk, prologue) - sock.do_handshake() + try: + sock.do_handshake() + except OTANetworkError as err: + # A transport fault: retry encrypted before considering plaintext + raise OTAHandshakeNetworkError(str(err)) from err + except OTAError as err: + # Remove before 2027.3.0 + if plaintext_fallback: + raise OTAEncryptionFallback(str(err)) from err + raise _LOGGER.info("Encrypted connection established") if ota_type != OTA_TYPE_UPDATE_APP: @@ -757,6 +820,7 @@ def run_ota_impl_( filename: Path, ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, + plaintext_fallback: bool = False, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -795,7 +859,9 @@ def run_ota_impl_( total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" reached_device = False - for attempt in range(total_attempts): + attempt = 0 + encryption = _EncryptionAttempt(noise_psk, plaintext_fallback) + while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if reached_device or attempt >= len(res): _LOGGER.info( @@ -815,17 +881,40 @@ def run_ota_impl_( sock.close() _LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err) last_error = f"connecting to {sa[0]} failed: {err}" + attempt += 1 continue _LOGGER.info("Connected to %s", sa[0]) reached_device = True with contextlib.closing(sock), Path(filename).open("rb") as file_handle: try: - perform_ota(sock, password, file_handle, filename, ota_type, noise_psk) + perform_ota( + sock, + password, + file_handle, + filename, + ota_type, + encryption.noise_psk, + encryption.plaintext_fallback, + ) + except OTAEncryptionFallback as err: + # Same address and attempt budget: not a network retry + last_error = str(err) + encryption.downgrade(last_error) + continue + except OTAHandshakeNetworkError as err: + last_error = str(err) + if encryption.handshake_fault_falls_back(): + encryption.downgrade(last_error) + continue + _LOGGER.warning("%s", last_error) + attempt += 1 + continue except OTANetworkError as err: # Transient network failure; retry last_error = str(err) _LOGGER.warning("%s", last_error) + attempt += 1 continue except OTAError as err: # Device-reported error (wrong password, wrong flash size, ...); @@ -847,10 +936,17 @@ def run_ota( filename: Path, ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, + plaintext_fallback: bool = False, ) -> tuple[int, str | None]: try: return run_ota_impl_( - remote_host, remote_port, password, filename, ota_type, noise_psk + remote_host, + remote_port, + password, + filename, + ota_type, + noise_psk, + plaintext_fallback, ) except OTAError as err: _LOGGER.error(err) diff --git a/esphome/wizard.py b/esphome/wizard.py index f7706928e92..897d5f60a14 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -148,11 +148,13 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: if "api_encryption_key" in kwargs: config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n' - # Configure OTA + # The api key also secures OTA; a password only serves older uploaders config += "\nota:\n" config += " - platform: esphome\n" if "ota_password" in kwargs: config += f' password: "{kwargs["ota_password"]}"' + elif "api_encryption_key" in kwargs: + config += " encryption:" # Configuring wifi config += "\n\nwifi:\n" @@ -529,20 +531,9 @@ def wizard(path: Path) -> int: safe_print() safe_print("You'll need this key when adding the device to Home Assistant.") sleep(1) - - safe_print() - safe_print( - f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? " - "This can be insecure if you do not trust the WiFi network." - ) - safe_print() - sleep(0.25) - safe_print("Press ENTER for no password") - ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) else: ssid, psk = "", "" api_encryption_key = None - ota_password = "" kwargs = { "path": path, @@ -553,10 +544,9 @@ def wizard(path: Path) -> int: "psk": psk, "type": "basic", } + # The api key also secures OTA updates, so the wizard sets no OTA password if api_encryption_key: kwargs["api_encryption_key"] = api_encryption_key - if ota_password: - kwargs["ota_password"] = ota_password if not wizard_write(**kwargs): return 1 diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py index 10f1eb3d4c3..2b79bd5464f 100644 --- a/tests/component_tests/noise/test_encryption_key.py +++ b/tests/component_tests/noise/test_encryption_key.py @@ -5,11 +5,7 @@ from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.noise import ( - decode_encryption_key, - is_reserved_key, - validate_encryption_key, -) +from esphome.components.noise import decode_encryption_key, validate_encryption_key KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @@ -41,6 +37,8 @@ def test_decode_encryption_key_rejects_short_decode() -> None: decode_encryption_key("AAECAw==") -def test_is_reserved_key() -> None: - assert is_reserved_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") - assert not is_reserved_key(KEY) +def test_validate_encryption_key_rejects_all_zeros() -> None: + """The all-zeros key is the provisioning sentinel the device treats as no + key, so it never reaches a build.""" + with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): + validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 873f1625554..d3092294dc1 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any @@ -14,6 +15,7 @@ from esphome.components.esphome.ota import ( _validate_no_password_with_encryption, ota_esphome_final_validate, ) +from esphome.components.noise import static_encryption_key from esphome.const import ( CONF_API, CONF_ENCRYPTION, @@ -115,7 +117,6 @@ def test_non_esphome_ota_unaffected() -> None: API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" -ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" def test_encryption_key_inherited_from_api() -> None: @@ -197,36 +198,6 @@ def test_encryption_without_any_key_rejected() -> None: fv.full_config.reset(token) -def test_encryption_explicit_all_zeros_key_rejected() -> None: - """The all-zeros key is the provisioning sentinel; the device would treat - it as no PSK and accept plaintext, so it must fail validation.""" - full_conf = { - CONF_OTA: [ - _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}}) - ], - } - token = fv.full_config.set(full_conf) - try: - with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): - ota_esphome_final_validate({}) - finally: - fv.full_config.reset(token) - - -def test_encryption_inherited_all_zeros_key_rejected() -> None: - """An all-zeros api key must not silently disable ota encryption either.""" - full_conf = { - CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}}, - CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})], - } - token = fv.full_config.set(full_conf) - try: - with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): - ota_esphome_final_validate({}) - finally: - fv.full_config.reset(token) - - def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -295,13 +266,14 @@ def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None fv.full_config.reset(token) +@pytest.mark.parametrize("component", ["web_server", "prometheus"]) def test_encryption_with_web_server_ota_warns( - caplog: pytest.LogCaptureFixture, + caplog: pytest.LogCaptureFixture, component: str ) -> None: - """With the web_server component the plaintext /update endpoint is always - on; the combination validates with a warning.""" + """web_server and prometheus keep the shared listener up, so the + plaintext /update endpoint is always on and the combination warns.""" full_conf = { - "web_server": {}, + component: {}, CONF_OTA: [ _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, @@ -316,12 +288,12 @@ def test_encryption_with_web_server_ota_warns( fv.full_config.reset(token) -def test_encryption_with_captive_portal_web_server_ota_warns( +def test_encryption_with_captive_portal_does_not_warn( caplog: pytest.LogCaptureFixture, ) -> None: """captive_portal auto-loads the web_server ota platform without the - web_server component; encryption stays usable and only warns, so the - fallback AP recovery path is not lost.""" + web_server component; its endpoint only exists while the fallback AP is + active and is the intended recovery path, so there is no warning.""" full_conf = { "captive_portal": {}, CONF_OTA: [ @@ -333,7 +305,10 @@ def test_encryption_with_captive_portal_web_server_ota_warns( try: with caplog.at_level(logging.WARNING): ota_esphome_final_validate({}) - assert any("captive_portal" in record.message for record in caplog.records) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) esphome_conf = next( conf for conf in fv.full_config.get()[CONF_OTA] @@ -344,6 +319,100 @@ def test_encryption_with_captive_portal_web_server_ota_warns( fv.full_config.reset(token) +def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: + """A static api key makes the device offer encryption and the CLI take + it, so the password is dead weight; the config validates with a warning.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert any("wastes significant flash" in r.message for r in caplog.records) + finally: + fv.full_config.reset(token) + + +def test_password_with_runtime_api_key_warns_differently( + caplog: pytest.LogCaptureFixture, +) -> None: + """The CLI still needs the password, but the provisioned key also + authenticates uploads; the warning says so without the flash advice.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {}}, + CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + messages = [r.message for r in caplog.records] + assert any("provisioned at runtime also authenticates" in m for m in messages) + assert not any("wastes significant flash" in m for m in messages) + finally: + fv.full_config.reset(token) + + +def test_password_without_api_key_no_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an api key there is no offer, so nothing to warn about.""" + full_conf = { + CONF_API: {}, + CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any("authenticates" in r.message for r in caplog.records) + finally: + fv.full_config.reset(token) + + +def test_web_server_component_without_ota_platform_does_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """The web_server component alone has no /update endpoint.""" + full_conf = { + "web_server": {}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}) + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in r.message for r in caplog.records + ) + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_platform_alone_does_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the web_server component starts the shared listener, so the ota + platform on its own never exposes /update.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any("plaintext /update" in r.message for r in caplog.records) + finally: + fv.full_config.reset(token) + + def test_web_server_ota_without_encryption_unaffected() -> None: """web_server ota stays valid alongside an unencrypted esphome entry.""" full_conf = { @@ -370,20 +439,87 @@ def test_auto_load_pulls_noise_only_for_encryption() -> None: assert "noise" in AUTO_LOAD({}) -def test_filter_source_files_excludes_noise_without_encryption() -> None: - """The noise transport source compiles only for encrypted builds.""" - old_config = CORE.config - try: - CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]} - assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"] - CORE.config = { - CONF_OTA: [ - _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) - ] - } - assert FILTER_SOURCE_FILES() == [] - finally: - CORE.config = old_config +def test_static_encryption_key() -> None: + """Only a build-time key counts; a runtime provisioned one does not.""" + assert static_encryption_key({}) is None + assert static_encryption_key({CONF_ENCRYPTION: {}}) is None + assert static_encryption_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY + + +@pytest.mark.parametrize( + ("yaml_name", "defines_present", "defines_absent"), + [ + # An api key alone compiles the transport in without requiring it; + # the device uses the api server's key, not a copy + ( + "api_key_offer", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), + # A password still guards plaintext uploads on an offering device + ( + "api_key_offer_password", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"}, + {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), + # The ota encryption block is what makes the device refuse plaintext + ( + "encryption_required", + { + "USE_OTA_ENCRYPTION", + "USE_OTA_ENCRYPTION_REQUIRED", + "USE_OTA_ENCRYPTION_FROM_API", + }, + {"USE_OTA_ENCRYPTION_PROVISIONED"}, + ), + # Without api encryption the ota key is the device's own + ( + "own_key", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, + {"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), + # A key provisioned at runtime lives in the api server; the device + # offers with it once provisioned and never requires it + ( + "runtime_api_key", + { + "USE_OTA_ENCRYPTION", + "USE_OTA_ENCRYPTION_FROM_API", + "USE_OTA_ENCRYPTION_PROVISIONED", + }, + {"USE_OTA_ENCRYPTION_REQUIRED"}, + ), + # No api encryption at all keeps the noise glue out of the build + ( + "plain", + set(), + { + "USE_OTA_ENCRYPTION", + "USE_OTA_ENCRYPTION_REQUIRED", + "USE_OTA_ENCRYPTION_FROM_API", + "USE_OTA_ENCRYPTION_PROVISIONED", + }, + ), + ], +) +def test_encryption_offer_codegen( + generate_main: Callable[[str], str], + yaml_name: str, + defines_present: set[str], + defines_absent: set[str], +) -> None: + main_cpp = generate_main( + f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml" + ) + defines = {define.name for define in CORE.defines} + assert defines_present <= defines + assert not (defines_absent & defines) + encrypted = "USE_OTA_ENCRYPTION" in defines_present + own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present + assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) + # The noise transport source compiles only when the define is set + assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) def test_password_with_encryption_rejected() -> None: diff --git a/tests/component_tests/ota/test_esphome_ota_api_key_offer.yaml b/tests/component_tests/ota/test_esphome_ota_api_key_offer.yaml new file mode 100644 index 00000000000..ca26eb9f466 --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_api_key_offer.yaml @@ -0,0 +1,11 @@ +esphome: + name: ota-offer + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome diff --git a/tests/component_tests/ota/test_esphome_ota_api_key_offer_password.yaml b/tests/component_tests/ota/test_esphome_ota_api_key_offer_password.yaml new file mode 100644 index 00000000000..1e239756904 --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_api_key_offer_password.yaml @@ -0,0 +1,12 @@ +esphome: + name: ota-offer-password + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" diff --git a/tests/component_tests/ota/test_esphome_ota_encryption_required.yaml b/tests/component_tests/ota/test_esphome_ota_encryption_required.yaml new file mode 100644 index 00000000000..36690038d8a --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_encryption_required.yaml @@ -0,0 +1,12 @@ +esphome: + name: ota-encryption-required + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + encryption: diff --git a/tests/component_tests/ota/test_esphome_ota_own_key.yaml b/tests/component_tests/ota/test_esphome_ota_own_key.yaml new file mode 100644 index 00000000000..b6d1e4200da --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_own_key.yaml @@ -0,0 +1,11 @@ +esphome: + name: ota-own-key + +host: + +api: + +ota: + - platform: esphome + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" diff --git a/tests/component_tests/ota/test_esphome_ota_plain.yaml b/tests/component_tests/ota/test_esphome_ota_plain.yaml new file mode 100644 index 00000000000..c5ca7afcf0d --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_plain.yaml @@ -0,0 +1,9 @@ +esphome: + name: ota-plain + +host: + +api: + +ota: + - platform: esphome diff --git a/tests/component_tests/ota/test_esphome_ota_runtime_api_key.yaml b/tests/component_tests/ota/test_esphome_ota_runtime_api_key.yaml new file mode 100644 index 00000000000..8825335141f --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_runtime_api_key.yaml @@ -0,0 +1,10 @@ +esphome: + name: ota-runtime-key + +host: + +api: + encryption: + +ota: + - platform: esphome diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp index d879a26c43f..f2081f29650 100644 --- a/tests/components/noise/test_noise_handshake.cpp +++ b/tests/components/noise/test_noise_handshake.cpp @@ -68,6 +68,14 @@ class Initiator { static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'}; +// The context only points at the key and init() copies it before returning, +// so a temporary context over a temporary key is safe within one call +static NoiseContext ctx_for(const psk_t &psk) { + NoiseContext ctx; + ctx.set_psk(psk.data()); + return ctx; +} + static psk_t make_psk(uint8_t seed) { psk_t psk; for (size_t i = 0; i < psk.size(); i++) { @@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) { TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) { const psk_t psk = make_psk(7); NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); EXPECT_EQ(responder.action(), Action::ACTION_READ); Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE)); @@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) { // proves the restart took effect; the old state surviving would fail the // MAC here. NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0); - ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0); EXPECT_EQ(responder.action(), Action::ACTION_READ); Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE)); @@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) { TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) { NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0); Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE)); uint8_t msg[MAX_HANDSHAKE_SIZE]; @@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) { // tampered preamble must fail even with the right key. const psk_t psk = make_psk(7); NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); static const uint8_t TAMPERED[] = {'x'}; Initiator initiator(psk, TAMPERED, sizeof(TAMPERED)); diff --git a/tests/components/noise/test_noise_primitives.cpp b/tests/components/noise/test_noise_primitives.cpp index 018be9f7175..8687c4b963b 100644 --- a/tests/components/noise/test_noise_primitives.cpp +++ b/tests/components/noise/test_noise_primitives.cpp @@ -17,12 +17,17 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) { EXPECT_FALSE(NoiseContext::is_all_zeros(psk)); NoiseContext ctx; + psk_t loaded; EXPECT_FALSE(ctx.has_psk()); - ctx.set_psk(zeros); - EXPECT_FALSE(ctx.has_psk()); - ctx.set_psk(psk); + ctx.load_psk(loaded); + EXPECT_EQ(loaded, zeros); + ctx.set_psk(psk.data()); EXPECT_TRUE(ctx.has_psk()); - EXPECT_EQ(ctx.get_psk(), psk); + ctx.load_psk(loaded); + EXPECT_EQ(loaded, psk); + // Callers map the reserved key to nullptr; the context just stores what it is given + ctx.set_psk(nullptr); + EXPECT_FALSE(ctx.has_psk()); } TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) { diff --git a/tests/components/ota/api_key_offer.yaml b/tests/components/ota/api_key_offer.yaml new file mode 100644 index 00000000000..8d1814bf7e7 --- /dev/null +++ b/tests/components/ota/api_key_offer.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + port: 3290 + password: "superlongpasswordthatnoonewillknow" diff --git a/tests/components/ota/api_runtime_key.yaml b/tests/components/ota/api_runtime_key.yaml new file mode 100644 index 00000000000..8976c92f961 --- /dev/null +++ b/tests/components/ota/api_runtime_key.yaml @@ -0,0 +1,10 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + +ota: + - platform: esphome + port: 3291 diff --git a/tests/components/ota/test-api_key_offer.esp32-idf.yaml b/tests/components/ota/test-api_key_offer.esp32-idf.yaml new file mode 100644 index 00000000000..ecda6255219 --- /dev/null +++ b/tests/components/ota/test-api_key_offer.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml new file mode 100644 index 00000000000..ecda6255219 --- /dev/null +++ b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp32-idf.yaml b/tests/components/ota/test-api_runtime_key.esp32-idf.yaml new file mode 100644 index 00000000000..4709a9e45ce --- /dev/null +++ b/tests/components/ota/test-api_runtime_key.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include api_runtime_key.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml new file mode 100644 index 00000000000..4709a9e45ce --- /dev/null +++ b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include api_runtime_key.yaml diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6777e6cabc2..15c58608799 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -162,6 +162,13 @@ def integration_test_dir() -> Generator[Path]: yield Path(tmpdir) +@pytest.fixture +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Host preferences persist per device name; give the test its own so a + provisioned key never leaks into another run.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + @pytest.fixture def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: """Reserve an unused TCP port by holding the socket open.""" diff --git a/tests/integration/const.py b/tests/integration/const.py index 6876bbd443b..e35d4673af9 100644 --- a/tests/integration/const.py +++ b/tests/integration/const.py @@ -9,6 +9,13 @@ API_CONNECTION_TIMEOUT = 30.0 # seconds PORT_WAIT_TIMEOUT = 30.0 # seconds PORT_POLL_INTERVAL = 0.1 # seconds +# The well-known all-zeros provisioning PSK, a key to provision over it, and +# the time the device takes to activate a newly saved key (100 ms timer plus +# margin) +ZERO_PSK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +PROVISIONING_PSK = b"bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4=" +KEY_ACTIVATION_DELAY = 0.5 # seconds + # Process shutdown timeouts SIGINT_TIMEOUT = 5.0 # seconds SIGTERM_TIMEOUT = 2.0 # seconds diff --git a/tests/integration/fixtures/host_ota_api_key_offer_with_password.yaml b/tests/integration/fixtures/host_ota_api_key_offer_with_password.yaml new file mode 100644 index 00000000000..1dedcc9ee1d --- /dev/null +++ b/tests/integration/fixtures/host_ota_api_key_offer_with_password.yaml @@ -0,0 +1,12 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + password: "hunter2" +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key.yaml new file mode 100644 index 00000000000..aa0a9a66c9a --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key.yaml @@ -0,0 +1,10 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py index bcea2a24719..f315335d1bc 100644 --- a/tests/integration/test_api_zero_psk_provisioning.py +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -10,34 +10,40 @@ from __future__ import annotations import asyncio import base64 +import socket from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError import pytest -from .types import APIClientConnectedFactory, RunCompiledFunction +from .conftest import run_binary_and_wait_for_port +from .const import KEY_ACTIVATION_DELAY, LOCALHOST, PROVISIONING_PSK, ZERO_PSK +from .types import ( + APIClientConnectedFactory, + CompileFunction, + ConfigWriter, + RunCompiledFunction, +) -# The well-known provisioning PSK: base64 of 32 zero bytes -ZERO_PSK = base64.b64encode(bytes(32)).decode() -# A real key to provision -NEW_KEY = base64.b64encode(b"n" * 32) -# Time for the device to activate a newly saved key (100ms timer plus margin) -KEY_ACTIVATION_DELAY = 0.5 - - -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so every run starts unprovisioned.""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) +pytestmark = pytest.mark.usefixtures("isolated_preferences") +NEW_KEY = PROVISIONING_PSK @pytest.mark.asyncio async def test_api_zero_psk_provisioning( yaml_config: str, - run_compiled: RunCompiledFunction, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], api_client_connected: APIClientConnectedFactory, ) -> None: - """Exercise the reject paths, then provision a key over the zero-PSK channel.""" - async with run_compiled(yaml_config): + """Exercise the reject paths, provision a key over the zero-PSK channel, + and check the key comes back from preferences on the next boot.""" + port, port_socket = reserved_tcp_port + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + port_socket.close() + + async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port): # --- Pre-provisioning reject paths (device state is unchanged) --- # A wrong (non-zero) PSK fails against the zero provisioning PSK @@ -97,6 +103,19 @@ async def test_api_zero_psk_provisioning( async with api_client_connected(timeout=5) as client: await client.device_info() + # The key is loaded from preferences on the next boot + lines: list[str] = [] + async with run_binary_and_wait_for_port( + binary_path, LOCALHOST, port, line_callback=lines.append + ): + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.api_encryption_provisionable is False + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + assert any("Loaded saved Noise PSK" in line for line in lines) + @pytest.mark.asyncio async def test_api_zero_psk_provisioning_plaintext( diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 4e748145342..f8c122c6e1c 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -8,9 +8,12 @@ instance covers the FD_CLOEXEC path. from __future__ import annotations import asyncio +import base64 from collections.abc import Generator from contextlib import contextmanager +from dataclasses import dataclass import functools +from pathlib import Path import socket import pytest @@ -18,10 +21,18 @@ import pytest from esphome import espota2 from .conftest import run_binary, wait_and_connect_api_client -from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT -from .types import CompileFunction, ConfigWriter +from .const import ( + KEY_ACTIVATION_DELAY, + LOCALHOST, + PORT_POLL_INTERVAL, + PORT_WAIT_TIMEOUT, + PROVISIONING_PSK, + ZERO_PSK, +) +from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter DEVICE_NAME = "host-ota-test" +API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @contextmanager @@ -35,6 +46,14 @@ def _reserve_port() -> Generator[tuple[int, socket.socket]]: s.close() +async def _wait_for_line(lines: list[str], needle: str, timeout: float = 5.0) -> None: + """The config dump prints after every setup, a little after the api port + opens, so wait for it rather than assert on the lines seen so far.""" + async with asyncio.timeout(timeout): + while not any(needle in line for line in lines): + await asyncio.sleep(PORT_POLL_INTERVAL) + + async def _wait_for_port(host: str, port: int, timeout: float) -> None: """Poll until a TCP port accepts connections, or raise TimeoutError.""" loop = asyncio.get_running_loop() @@ -51,6 +70,102 @@ async def _wait_for_port(host: str, port: int, timeout: float) -> None: raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s") +async def _build( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> tuple[int, int, Path]: + """Reserve an OTA port, compile the fixture with it, and release both + ports right before the binary is started.""" + api_port, api_socket = reserved_tcp_port + with _reserve_port() as (ota_port, ota_socket): + config_path = await write_yaml_config( + yaml_config.replace("__OTA_PORT__", str(ota_port)) + ) + binary_path = await compile_esphome(config_path) + api_socket.close() + ota_socket.close() + return api_port, ota_port, binary_path + + +async def _run_ota( + ota_port: int, + password: str | None, + binary_path: Path, + noise_psk: str | None, + plaintext_fallback: bool = False, +) -> int: + """espota2 is blocking; run it in the executor and return its exit code.""" + rc, _ = await asyncio.get_running_loop().run_in_executor( + None, + functools.partial( + espota2.run_ota, + LOCALHOST, + ota_port, + password, + binary_path, + noise_psk=noise_psk, + plaintext_fallback=plaintext_fallback, + ), + ) + return rc + + +@dataclass +class _Device: + """A running host binary and the checks every successful OTA repeats: + a safe reboot, the api port back up, and the pid preserved by execv.""" + + api_port: int + ota_port: int + binary_path: Path + proc: asyncio.subprocess.Process | None = None + reboots: int = 0 + + def __post_init__(self) -> None: + self._rebooted = asyncio.Event() + + def on_log(self, line: str) -> None: + if "Rebooting safely" in line: + self.reboots += 1 + self._rebooted.set() + + async def wait_reboot(self, count: int, timeout: float = 10.0) -> None: + async with asyncio.timeout(timeout): + while self.reboots < count: + self._rebooted.clear() + await self._rebooted.wait() + + async def ota( + self, + password: str | None, + noise_psk: str | None, + msg: str, + plaintext_fallback: bool = False, + ) -> None: + """Upload, then expect the re-exec with the pid preserved.""" + pid_before = self.proc.pid + expected_reboots = self.reboots + 1 + rc = await _run_ota( + self.ota_port, password, self.binary_path, noise_psk, plaintext_fallback + ) + assert rc == 0, msg + await self.wait_reboot(expected_reboots) + await _wait_for_port(LOCALHOST, self.api_port, PORT_WAIT_TIMEOUT) + assert self.proc.returncode is None, "process exited instead of execing" + assert self.proc.pid == pid_before + + async def refused_ota( + self, password: str | None, noise_psk: str | None, msg: str + ) -> None: + """Upload must fail and the device must keep running.""" + rc = await _run_ota(self.ota_port, password, self.binary_path, noise_psk) + assert rc == 1, msg + await asyncio.sleep(0.5) + assert self.proc.returncode is None, "process died on rejected OTA" + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -59,57 +174,34 @@ async def test_host_ota_self_update( reserved_tcp_port: tuple[int, socket.socket], ) -> None: """Self-OTA: upload the running binary back to itself, expect re-exec.""" - api_port, api_socket = reserved_tcp_port - with _reserve_port() as (ota_port, ota_socket): - yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - api_socket.close() - ota_socket.close() + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + staged = asyncio.Event() - loop = asyncio.get_running_loop() - ota_staged = loop.create_future() - rebooted = loop.create_future() + def on_log(line: str) -> None: + if "OTA staged at" in line: + staged.set() + dev.on_log(line) - def on_log(line: str) -> None: - if not ota_staged.done() and "OTA staged at" in line: - ota_staged.set_result(True) - if not rebooted.done() and "Rebooting safely" in line: - rebooted.set_result(True) + async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + async with wait_and_connect_api_client(port=dev.api_port) as client: + info_before = await client.device_info() + assert info_before.name == DEVICE_NAME - async with run_binary(binary_path, line_callback=on_log) as (proc, _lines): - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - pid_before = proc.pid - async with wait_and_connect_api_client(port=api_port) as client: - info_before = await client.device_info() - assert info_before.name == DEVICE_NAME + await dev.ota(None, None, "espota2 reported failure") + assert staged.is_set() - # espota2 is blocking; run in executor. - rc, _ = await loop.run_in_executor( - None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path - ) - assert rc == 0, "espota2 reported failure" + async with wait_and_connect_api_client(port=dev.api_port) as client: + info_after = await client.device_info() + assert info_after.name == info_before.name - await asyncio.wait_for(ota_staged, timeout=10.0) - await asyncio.wait_for(rebooted, timeout=10.0) - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - - # execv preserves pid; mismatch means external respawn. - assert proc.returncode is None, "process exited instead of execing" - assert proc.pid == pid_before - - async with wait_and_connect_api_client(port=api_port) as client: - info_after = await client.device_info() - assert info_after.name == DEVICE_NAME - assert info_after.name == info_before.name - - # Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind). - rc, _ = await loop.run_in_executor( - None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path - ) - assert rc == 0, "second OTA failed -- listener leaked across execv" - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - assert proc.pid == pid_before + # Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind). + await dev.ota(None, None, "second OTA failed -- listener leaked across execv") @pytest.mark.asyncio @@ -121,51 +213,110 @@ async def test_host_ota_encrypted( ) -> None: """Encrypted self-OTA succeeds; a plaintext upload to the same device fails.""" pytest.importorskip("aioesphomeapi.noise") - noise_psk = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" - api_port, api_socket = reserved_tcp_port - with _reserve_port() as (ota_port, ota_socket): - yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) - api_socket.close() - ota_socket.close() + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await dev.refused_ota( + None, None, "plaintext upload to an encrypted device must fail" + ) + await dev.ota(None, API_KEY, "encrypted OTA reported failure") - loop = asyncio.get_running_loop() - rebooted = loop.create_future() - def on_log(line: str) -> None: - if not rebooted.done() and "Rebooting safely" in line: - rebooted.set_result(True) +@pytest.mark.asyncio +async def test_host_ota_api_key_offer_with_password( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + caplog: pytest.LogCaptureFixture, +) -> None: + """With only an api key the device offers encryption without requiring + it: the password still guards plaintext uploads, the key alone + authenticates an encrypted one, and until 2027.3.0 a failed encrypted + attempt falls back to plaintext.""" + pytest.importorskip("aioesphomeapi.noise") + wrong_key = base64.b64encode(b"w" * 32).decode() + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _wait_for_line(lines, "Encryption: offered") - async with run_binary(binary_path, line_callback=on_log) as (proc, _lines): - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - pid_before = proc.pid + await dev.refused_ota( + None, None, "plaintext upload without the password must fail" + ) + await dev.ota( + "hunter2", None, "plaintext upload with the password must succeed" + ) + await dev.ota(None, API_KEY, "encrypted upload with the api key must succeed") - # A plaintext upload must be refused with the device unharmed - rc, _ = await loop.run_in_executor( - None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path + # Remove before 2027.3.0: a wrong key falls back to plaintext, which + # the password still guards + with caplog.at_level("WARNING", logger="esphome.espota2"): + await dev.ota( + "hunter2", + wrong_key, + "the plaintext retry with the password must succeed", + plaintext_fallback=True, ) - assert rc == 1, "plaintext upload to an encrypted device must fail" - await asyncio.sleep(0.5) - assert proc.returncode is None, "process died on rejected plaintext OTA" + assert any("Retrying in plaintext" in r.message for r in caplog.records) + await dev.ota( + None, + API_KEY, + "the right api key encrypts without touching the fallback", + plaintext_fallback=True, + ) - # The encrypted upload goes through and the device re-execs - rc, _ = await loop.run_in_executor( - None, - functools.partial( - espota2.run_ota, - LOCALHOST, - ota_port, - None, - binary_path, - noise_psk=noise_psk, - ), - ) - assert rc == 0, "encrypted OTA reported failure" - await asyncio.wait_for(rebooted, timeout=10.0) - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - assert proc.returncode is None, "process exited instead of execing" - assert proc.pid == pid_before + +@pytest.mark.asyncio +@pytest.mark.usefixtures("isolated_preferences") +async def test_host_ota_provisioned_api_key( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """A key provisioned over the api feeds the OTA offer: plaintext works + while unprovisioned, the provisioned key encrypts, the key loaded from + preferences on the next boot keeps encrypting, and plaintext stays + accepted because only the ota block requires encryption.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _wait_for_line(lines, "once the api key is provisioned") + + await dev.ota( + None, None, "plaintext upload to an unprovisioned device must succeed" + ) + + async with api_client_connected( + port=dev.api_port, noise_psk=ZERO_PSK + ) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + key = PROVISIONING_PSK.decode() + await dev.ota( + None, key, "encrypted upload with the provisioned key must succeed" + ) + await dev.ota(None, key, "the key loaded at boot must feed the OTA offer") + await dev.ota(None, None, "plaintext must stay accepted on an offering device") @pytest.mark.asyncio @@ -177,33 +328,25 @@ async def test_host_ota_rejects_garbage( integration_test_dir, ) -> None: """Bogus payload is rejected and the device keeps running.""" - api_port, api_socket = reserved_tcp_port - with _reserve_port() as (ota_port, ota_socket): - yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port)) - config_path = await write_yaml_config(yaml_config) - binary_path = await compile_esphome(config_path) + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # 192 bytes that are neither ELF nor Mach-O. + bogus_path = integration_test_dir / "bogus.bin" + bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) - # 192 bytes that are neither ELF nor Mach-O. - bogus_path = integration_test_dir / "bogus.bin" - bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8) + async with run_binary(dev.binary_path) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + pid_before = proc.pid + rc = await _run_ota(dev.ota_port, None, bogus_path, None) + assert rc == 1 + await asyncio.sleep(0.5) + assert proc.returncode is None, "process died on rejected OTA" + assert proc.pid == pid_before - api_socket.close() - ota_socket.close() - - async with run_binary(binary_path) as (proc, _lines): - await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT) - pid_before = proc.pid - - loop = asyncio.get_running_loop() - rc, _ = await loop.run_in_executor( - None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path - ) - assert rc == 1 - - await asyncio.sleep(0.5) - assert proc.returncode is None, "process died on rejected OTA" - assert proc.pid == pid_before - - async with wait_and_connect_api_client(port=api_port) as client: - info = await client.device_info() - assert info.name == DEVICE_NAME + async with wait_and_connect_api_client(port=dev.api_port) as client: + info = await client.device_info() + assert info.name == DEVICE_NAME diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 5b43d05530e..439220f09c6 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -10,12 +10,15 @@ when the installed aioesphomeapi predates the noise module. from __future__ import annotations import base64 +from collections.abc import Callable import hashlib import io +import logging from pathlib import Path import socket import sys import threading +from typing import Any from unittest.mock import Mock, patch import pytest @@ -65,8 +68,12 @@ class FakeEncryptedDevice(threading.Thread): offer_noise: bool = True, require_noise: bool = True, prologue_features_override: int | None = None, + connections: int = 1, + drop_handshakes: int = 0, ) -> None: super().__init__(daemon=True) + self.connections = connections + self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times self.psk = psk self.version = version self.offer_noise = offer_noise @@ -81,10 +88,11 @@ class FakeEncryptedDevice(threading.Thread): def run(self) -> None: try: - sock, _ = self.listener.accept() - sock.settimeout(10) - with sock: - self._serve(sock) + for _ in range(self.connections): + sock, _ = self.listener.accept() + sock.settimeout(10) + with sock: + self._serve(sock) except Exception as err: # noqa: BLE001 - surfaced via join_and_check self.error = err finally: @@ -109,8 +117,23 @@ class FakeEncryptedDevice(threading.Thread): return server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0 sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])) - if not (self.offer_noise and noise_negotiated): - return # the client fails closed; nothing further arrives + if not (noise_negotiated and self.offer_noise): + # A device that does not require encryption continues in + # plaintext whatever the client asked for, like older firmware + try: + self._transfer( + lambda byte: sock.sendall(bytes([byte])), + lambda length: _recv_exact(sock, length), + lambda remaining: _recv_exact( + sock, min(remaining, espota2.UPLOAD_BLOCK_SIZE) + ), + ) + except ConnectionError: + # A keyed client without fallback fails closed and hangs up + if noise_negotiated and not self.offer_noise: + return + raise + return from cryptography.exceptions import InvalidTag from noise.connection import NoiseConnection @@ -134,6 +157,9 @@ class FakeEncryptedDevice(threading.Thread): msg1 = _recv_frame(sock) assert msg1[0] == 0x00 + if self.drop_handshakes > 0: + self.drop_handshakes -= 1 + return # a transport fault: the socket closes with no reply try: proto.read_message(msg1[1:]) except InvalidTag: @@ -149,6 +175,20 @@ class FakeEncryptedDevice(threading.Thread): assert len(plaintext) == length, "control units must be one per frame" return plaintext + def recv_data(_remaining: int) -> bytes: + plaintext = proto.decrypt(_recv_frame(sock)) + assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT + return plaintext + + self._transfer(send_byte, recv_unit, recv_data) + + def _transfer( + self, + send_byte: Callable[[int], None], + recv_unit: Callable[[int], bytes], + recv_data: Callable[[int], bytes], + ) -> None: + """The post-handshake exchange, identical over both transports.""" send_byte(espota2.RESPONSE_AUTH_OK) recv_unit(1) # ota type size = int.from_bytes(recv_unit(4), "big") @@ -159,9 +199,7 @@ class FakeEncryptedDevice(threading.Thread): received = b"" acked = 0 while len(received) < size: - plaintext = proto.decrypt(_recv_frame(sock)) - assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT - received += plaintext + received += recv_data(size - len(received)) if self.version >= espota2.OTA_VERSION_2_0: while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or ( len(received) == size and acked < size @@ -176,7 +214,10 @@ class FakeEncryptedDevice(threading.Thread): def _upload( - device: FakeEncryptedDevice, firmware: bytes, noise_psk: str | None + device: FakeEncryptedDevice, + firmware: bytes, + noise_psk: str | None, + plaintext_fallback: bool = False, ) -> None: device.start() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -184,12 +225,35 @@ def _upload( sock.connect(("127.0.0.1", device.port)) try: espota2.perform_ota( - sock, None, io.BytesIO(firmware), Path("firmware.bin"), noise_psk=noise_psk + sock, + None, + io.BytesIO(firmware), + Path("firmware.bin"), + noise_psk=noise_psk, + plaintext_fallback=plaintext_fallback, ) finally: sock.close() +def _run_ota( + device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str +) -> int: + """Drive the retry loop, which is where the plaintext fallback reconnects.""" + path = tmp_path / "firmware.bin" + path.write_bytes(firmware) + device.start() + rc, _ = espota2.run_ota( + "127.0.0.1", + device.port, + None, + path, + noise_psk=noise_psk, + plaintext_fallback=True, + ) + return rc + + def test_encrypted_upload_success() -> None: """A full encrypted v2 upload spanning several 8192-byte blocks.""" pytest.importorskip("aioesphomeapi.noise") @@ -240,6 +304,56 @@ def test_client_fails_closed_when_device_lacks_encryption() -> None: device.join_and_check() +# Remove before 2027.3.0 +def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None: + """The api key is tried opportunistically; an older device that cannot + encrypt still gets its update, with a warning.""" + firmware = b"firmware" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, plaintext_fallback=True) + device.join_and_check() + assert device.received == firmware + assert any("fallback is removed in 2027.3.0" in r.message for r in caplog.records) + + +# Remove before 2027.3.0 +@pytest.mark.parametrize( + ("device_kwargs", "expected_rc", "fell_back"), + [ + # A wrong key against an offering device reconnects in plaintext + ({"psk": OTHER_PSK, "require_noise": False, "connections": 2}, 0, True), + # The plaintext retry is refused by a device that requires encryption + ({"psk": OTHER_PSK, "require_noise": True, "connections": 2}, 1, True), + # A dropped connection inside the handshake is retried encrypted + ({"require_noise": False, "connections": 2, "drop_handshakes": 1}, 0, False), + # A second transport fault inside the handshake falls back + ({"require_noise": False, "connections": 3, "drop_handshakes": 2}, 0, True), + ], + ids=["wrong_key", "wrong_key_required", "one_fault", "two_faults"], +) +def test_fallback_through_the_retry_loop( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + device_kwargs: dict[str, Any], + expected_rc: int, + fell_back: bool, +) -> None: + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice(**device_kwargs) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota(device, firmware, tmp_path, PSK) + device.join_and_check() + assert rc == expected_rc + assert (device.received == firmware) is (expected_rc == 0) + assert ( + any("Retrying in plaintext" in r.message for r in caplog.records) is fell_back + ) + if expected_rc == 1: + assert any("requires an encrypted OTA" in r.message for r in caplog.records) + + def test_plaintext_client_gets_encryption_required_error() -> None: """A client without a key gets the device's 0x94 error message.""" device = FakeEncryptedDevice() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 5372a7203dc..8fb9b7376e3 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2108,7 +2108,13 @@ def test_upload_program_ota_success( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP, None + ["192.168.1.100"], + 3232, + "secret", + expected_firmware, + OTA_TYPE_UPDATE_APP, + None, + plaintext_fallback=False, ) @@ -2140,10 +2146,77 @@ def test_upload_program_ota_encryption_key( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, key + ["192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, + key, + plaintext_fallback=False, ) +def test_upload_program_ota_api_key_opportunistic( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Without an ota encryption block the api key is tried with a plaintext + fallback (removed in 2027.3.0).""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: key}}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}], + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, + key, + plaintext_fallback=True, + ) + + +@pytest.mark.parametrize( + "api_conf", + [{}, {CONF_ENCRYPTION: {}}], + ids=["no_encryption", "runtime_key"], +) +def test_upload_program_ota_no_usable_api_key_stays_plaintext( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + api_conf: dict[str, Any], +) -> None: + """A missing or runtime provisioned api key gives the uploader nothing + to try.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_API: api_conf, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}], + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[5] is None + assert mock_run_ota.call_args.kwargs == {"plaintext_fallback": False} + + def test_upload_program_ota_encryption_without_key_fails_closed( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2194,7 +2267,13 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP, None + ["192.168.1.100"], + 3232, + None, + Path("custom.bin"), + OTA_TYPE_UPDATE_APP, + None, + plaintext_fallback=False, ) @@ -2250,6 +2329,7 @@ def test_upload_program_ota_partition_table_with_file_arg( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, + plaintext_fallback=False, ) @@ -2312,6 +2392,7 @@ def test_upload_program_ota_partition_table_mqttip( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, + plaintext_fallback=False, ) @@ -2500,6 +2581,7 @@ def test_upload_program_ota_bootloader_with_file_arg( bootloader_file, OTA_TYPE_UPDATE_BOOTLOADER, None, + plaintext_fallback=False, ) @@ -2988,7 +3070,13 @@ def test_upload_program_ota_with_mqtt_resolution( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None + ["192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, + None, + plaintext_fallback=False, ) @@ -3038,7 +3126,13 @@ def test_upload_program_ota_with_mqtt_empty_broker( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None + ["192.168.1.50"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, + None, + plaintext_fallback=False, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5211,6 +5305,7 @@ def test_upload_program_ota_static_ip_with_mqttip( expected_firmware, OTA_TYPE_UPDATE_APP, None, + plaintext_fallback=False, ) @@ -5261,6 +5356,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( expected_firmware, OTA_TYPE_UPDATE_APP, None, + plaintext_fallback=False, ) @@ -5438,7 +5534,13 @@ def test_upload_program_ota_mqtt_timeout_fallback( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None + ["192.168.1.100"], + 3232, + None, + expected_firmware, + OTA_TYPE_UPDATE_APP, + None, + plaintext_fallback=False, ) diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 244e4eb5a12..f57ae71ae6f 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -37,7 +37,6 @@ def wizard_answers() -> list[str]: "nodemcuv2", # board "SSID", # ssid "psk", # wifi password - "", # ota password (empty for no password) ] @@ -101,6 +100,25 @@ def test_config_file_should_include_ota(default_config: dict[str, Any]): assert "ota:" in config +def test_config_file_should_use_encryption_when_api_key_set( + default_config: dict[str, Any], +): + """ + With an API encryption key and no OTA password the OTA block reuses the key + """ + # Given + default_config["api_encryption_key"] = ( + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + ) + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "ota:\n - platform: esphome\n encryption:" in config + assert "password" not in config.split("ota:")[1].split("wifi:")[0] + + def test_config_file_should_include_ota_when_password_set( default_config: dict[str, Any], ): @@ -630,15 +648,15 @@ def test_wizard_write_protects_existing_config( assert config_file.read_text() == original_content -def test_wizard_accepts_ota_password( +def test_wizard_uses_the_api_key_for_ota( tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): """ - The wizard should pass ota_password to wizard_write when the user provides one + The wizard generates an api key and does not ask for an OTA password; + the key secures OTA updates """ # Given - wizard_answers[5] = "my_ota_password" # Set OTA password config_file = tmp_path / "test.yaml" input_mock = MagicMock(side_effect=wizard_answers) monkeypatch.setattr("builtins.input", input_mock) @@ -653,8 +671,9 @@ def test_wizard_accepts_ota_password( # Then assert retval == 0 call_kwargs = wizard_write_mock.call_args.kwargs - assert "ota_password" in call_kwargs - assert call_kwargs["ota_password"] == "my_ota_password" + assert "api_encryption_key" in call_kwargs + assert "ota_password" not in call_kwargs + assert input_mock.call_count == len(wizard_answers) def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): From 96b1a03ea493a7281158907c6dd98184a48c05f2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:05:16 +0000 Subject: [PATCH 015/759] Bump bundled esphome-device-builder to 1.14.4 (#19006) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e875851bfbd..da76ab7b6a7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.3 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4 RUN \ platformio settings set enable_telemetry No \ From c1aa41f276e4bc2b05f4b45031229623d93ab84a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:12:52 +0200 Subject: [PATCH 016/759] [noise] Bump noise-c to 0.1.24 and libsodium to 1.10021.6 (#18989) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index a1d9444fc00..4de706120e1 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.21") + cg.add_library("esphome/noise-c", "0.1.24") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.4") + cg.add_library("esphome/libsodium", "1.10021.6") # 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 fcf7caa7c7b..779a05e7de4 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.21 ; noise (api, ota) + esphome/noise-c@0.1.24 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.21 ; noise (api, ota) + esphome/noise-c@0.1.24 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.21 ; used by noise (api, ota) + esphome/noise-c@0.1.24 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index a263d7937fb..4f7f5a4a4c8 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" + " esphome/noise-c @ 0.1.24\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.21\n" + " esphome/noise-c @ 0.1.24\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.21"] + assert libs == ["esphome/noise-c @ 0.1.24"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.21", + "esphome/noise-c @ 0.1.24", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.21", - "esphome/noise-c @ 0.1.21", + "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.24", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.21"] + assert cls.calls == ["esphome/noise-c @ 0.1.24"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.21"] is None + assert compats["esphome/noise-c @ 0.1.24"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) cls.deps = { - "esphome/noise-c @ 0.1.21": [ + "esphome/noise-c @ 0.1.24": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 379ef52ebd7..fb79885736d 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1576,7 +1576,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1596,7 +1596,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.21", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 5d2ddc658c3db2431fb71dfc78dc2df885f1cf78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:26:44 +0200 Subject: [PATCH 017/759] [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 (#18990) --- esphome/components/mdns/__init__.py | 2 + esphome/components/mdns/mdns_esp8266.cpp | 51 +++++++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f039bb69f03..c8020104b37 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -192,6 +192,8 @@ async def to_code(config: ConfigType) -> None: if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) + # No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder + cg.add_build_flag("-DNO_GLOBAL_MDNS") elif CORE.is_rp2: cg.add_library("LEAmDNS", None) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 1f0b3c9519d..0e600d3bac1 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -13,8 +13,47 @@ namespace esphome::mdns { +// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and +// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a +// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides +// free the same tx pbufs (#18760). Received packets stay queued during such a call and are +// processed from the main loop afterwards. +class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder { + public: + void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); } + void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); } + + private: + void run_guarded_(bool (GuardedMDNSResponder::*fn)()) { + UdpContext *ctx = this->m_pUDPContext; + if (ctx == nullptr) { + (this->*fn)(); + return; + } + // Set every time: a restart replaces the context together with its stock handler. Only + // begin() and the scheduled netif callback restart, never update() or close(), so the + // context cannot change underneath this call. + ctx->onRx([this]() { + if (!this->in_loop_call_) { + this->_callProcess(); + } + }); + this->in_loop_call_ = true; + (this->*fn)(); + // close() releases the context; a yield in here queues further packets for this loop too + while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) { + this->_parseMessage(); + } + this->in_loop_call_ = false; + } + + volatile bool in_loop_call_{false}; +}; + +static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static void register_esp8266(MDNSComponent *, StaticVector &services) { - MDNS.begin(App.get_name().c_str()); + mdns_responder.begin(App.get_name().c_str()); for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is @@ -30,10 +69,10 @@ static void register_esp8266(MDNSComponent *, StaticVectoris_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) return; #endif - MDNS.update(); + mdns_responder.update_guarded(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } @@ -81,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: #endif void MDNSComponent::on_shutdown() { - MDNS.close(); + mdns_responder.close_guarded(); delay(10); } From e0e85db822309dd8fe17043552405c8c8b8374af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 00:28:31 +0200 Subject: [PATCH 018/759] [core] Show the other downloader's progress while a prefetch job waits on its lock (#18983) --- esphome/framework_helpers.py | 61 +++++++++++++- esphome/platformio/prefetch.py | 87 ++++++++++---------- esphome/platformio/registry.py | 67 ++++++++++----- tests/unit_tests/conftest.py | 39 ++++++++- tests/unit_tests/test_framework_helpers.py | 17 ++++ tests/unit_tests/test_platformio_prefetch.py | 87 ++++++++++++++++++-- tests/unit_tests/test_platformio_registry.py | 73 ++++++++++++++-- 7 files changed, 348 insertions(+), 83 deletions(-) diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 82bc0d37272..fc2a18a6ecd 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -23,6 +23,7 @@ from esphome.net_retry import ( ) if TYPE_CHECKING: + from filelock import FileLock import requests PathType = str | os.PathLike @@ -909,6 +910,61 @@ def _part_path(dest: Path) -> Path: return dest.with_name(dest.name + ".part") +def downloaded_bytes(dest: Path, size: int | None = None) -> int: + """Bytes of ``dest`` on disk (its ``.part`` while streaming), capped at ``size``.""" + done = 0 + for candidate in (_part_path(dest), dest): + try: + done = candidate.stat().st_size + break + except FileNotFoundError: + continue + return done if size is None else min(done, size) + + +# Short lock-acquire slices so a waiting worker still observes Ctrl-C +_DOWNLOAD_LOCK_POLL = 1 + +# Waiting on another process's download; past this the caller leaves the +# file to its holder (the later sequential install waits on the same lock) +DOWNLOAD_LOCK_TIMEOUT = 60 + + +class DownloadLockUnavailable(OSError): + """The lock file cannot be used at all (a lock-less filesystem).""" + + +def wait_for_download_lock( + lock: "FileLock", + tracker: Callable[[int], None], + on_disk: Callable[[], int], + name: str, +) -> None: + """Acquire ``lock``, reporting ``on_disk()`` to ``tracker`` each poll so the + bar follows the holder's download. Raises filelock's ``Timeout`` once + ``DOWNLOAD_LOCK_TIMEOUT`` seconds pass.""" + from filelock import Timeout + + deadline = time.monotonic() + DOWNLOAD_LOCK_TIMEOUT + waiting = False + while True: + try: + lock.acquire(timeout=_DOWNLOAD_LOCK_POLL) + return + except Timeout: + pass + except OSError as err: + # Distinct from an OSError out of on_disk(), which must not + # read as "locks unsupported" + raise DownloadLockUnavailable(*err.args) from err + if not waiting: + waiting = True + _LOGGER.info("Waiting for another process downloading %s", name) + tracker(on_disk()) # raises when the batch is cancelled + if time.monotonic() >= deadline: + raise Timeout(lock.lock_file) + + def discard_partial_download(dest: Path) -> None: """Remove ``dest`` and the resume sidecars of an abandoned download.""" part = _part_path(dest) @@ -1319,10 +1375,7 @@ def download_from_mirrors( ) # Tick with the bytes already on disk so a combined bar holds # steady during the backoff instead of rewinding to zero - done = 0 - if progress is not None: - part = _part_path(path_target) - done = part.stat().st_size if part.is_file() else 0 + done = downloaded_bytes(path_target) if progress is not None else 0 _cancellable_sleep(delay, progress, done) # 3. Report every attempted URL if all mirrors failed. failures spans diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 5097239065f..17a06cb9c10 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -33,11 +33,14 @@ import time from typing import Any, NamedTuple from esphome.framework_helpers import ( + DownloadLockUnavailable, content_length, discard_partial_download, + downloaded_bytes, failure_reason, resume_fetch_job, run_batch_downloads, + wait_for_download_lock, warn_prefetch_failures, ) from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree @@ -61,16 +64,10 @@ _RESOLVE_WORKERS = 8 # A hung child must not block the build; downloads resume on the next run _PREFETCH_TIMEOUT = 20 * 60 -# Waiting on another process's URL download; past this, leave it to pio -_DOWNLOAD_LOCK_TIMEOUT = 60 - # Child exit for a handled, already-warned failure; 1 would collide with # the interpreter's own import-failure exit _EXIT_HANDLED = 3 -# Short lock-acquire slices so a waiting worker still observes Ctrl-C -_URI_LOCK_POLL = 1 - # Resolution errored (vs a clean skip); suppresses the warm sentinel _RESOLVE_FAILED = object() @@ -462,51 +459,54 @@ def _uri_jobs( def _serialized_fetch_job( - dl_path: Path, lock_path: str, body: Any, unlocked_ok: bool = True + dl_path: Path, + lock_path: str, + body: Any, + size: int, + stream_dest: Path | None = None, + unlocked_ok: bool = True, ) -> Any: - """Wrap ``body`` so the shared destination is single-writer. - - Interleaved writers truncate each other's ``.part`` bytes (see - registry.py). The bounded poll observes Ctrl-C via the tracker; a - blown deadline is a clean skip (the holder's copy is what the build - needs). On a lock-less filesystem a sha256-verified body runs - unlocked with one warning; a checksum-less one - (``unlocked_ok=False``) is a counted failure instead. + """Wrap ``body`` so the shared destination is single-writer (interleaved + writers truncate each other's ``.part``, see registry.py). A blown deadline + is a clean skip. On a lock-less filesystem a sha256-verified body runs + unlocked with one warning; a checksum-less one (``unlocked_ok=False``) fails. """ + def on_disk() -> int: + # A URL job's holder streams beside the staging path until it + # promotes; after that only dl_path is left + done = downloaded_bytes(dl_path, size) + if not done and stream_dest is not None: + done = downloaded_bytes(stream_dest, size) + return done + def run(tracker: Any) -> None: from filelock import FileLock, Timeout # fallback_to_soft would leave a stale marker on lock-less # filesystems that blocks every later build (see git.py) lock = FileLock(lock_path, fallback_to_soft=False) - deadline = time.monotonic() + _DOWNLOAD_LOCK_TIMEOUT - while True: - try: - lock.acquire(timeout=_URI_LOCK_POLL) - break - except Timeout: - tracker(0) # raises when the batch is cancelled - if time.monotonic() >= deadline: - # Another process is fetching this same file; its copy - # is what the build needs (a large framework archive - # can hold the lock far longer than this deadline) - _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) - return - except OSError as err: - if not unlocked_ok: - # A body with no checksum to catch interleaved corruption - raise - lock = None - _LOGGER.warning( - "Could not lock %s (%s); downloading unlocked", - dl_path.name, - err, - ) - break + try: + wait_for_download_lock(lock, tracker, on_disk, dl_path.name) + except Timeout: + # The holder's copy is what the build needs (a large + # framework archive can outlast this deadline) + _LOGGER.debug("Leaving %s to its current downloader", dl_path.name) + return + except DownloadLockUnavailable as err: + if not unlocked_ok: + # A body with no checksum to catch interleaved corruption + raise + lock = None + _LOGGER.warning( + "Could not lock %s (%s); downloading unlocked", + dl_path.name, + err, + ) try: if dl_path.is_file(): - return # another process finished it while we waited + tracker(size) # another process finished it while we waited + return body(tracker) finally: if lock is not None: @@ -540,6 +540,7 @@ def _registry_fetch_job( dl_path, f"{dl_path}.esphome.lock", resume_fetch_job(url, dl_path, sha256=checksum, size=size), + size, ) def run(tracker: Any) -> None: @@ -571,9 +572,9 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any: tmp.replace(dl_path) def run(tracker: Any) -> None: - _serialized_fetch_job(dl_path, f"{tmp}.lock", promote, unlocked_ok=False)( - tracker - ) + _serialized_fetch_job( + dl_path, f"{tmp}.lock", promote, size, tmp, unlocked_ok=False + )(tracker) if dl_path.is_file(): # Won or lost, the race is over; staging files left behind # are dead weight PlatformIO's cache never prunes diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index 9538a28ff4d..75df82da0e9 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -17,8 +17,10 @@ from esphome.framework_helpers import ( archive_extract_all, download_from_mirrors, download_with_resume, + downloaded_bytes, rmdir, run_batch_downloads, + wait_for_download_lock, ) from esphome.net_retry import fetch_with_retry, http_request @@ -164,11 +166,17 @@ class _PendingArchive(NamedTuple): name: str version: str dest: Path + archive: Path url: str sha256: str size: int +def _archive_path(downloads_dir: Path, name: str, version: str) -> Path: + """The one archive path the prefetch and the sequential install share.""" + return downloads_dir / f"{name}-{version}" + + def _already_installed(dest: Path) -> bool: """Whether ``dest`` holds a completed install (extraction marker).""" return (dest / ".esphome_extracted").is_file() @@ -187,18 +195,18 @@ def prefetch_packages( lock as ``install_package``: the archive's ``.part`` file is shared, and two concurrent writers would truncate each other's bytes. """ - from filelock import FileLock + from filelock import FileLock, Timeout pending: list[_PendingArchive] = [] - seen: set[str] = set() + seen: set[Path] = set() for name, version, dest, mirrors in packages: if mirrors or (dest / ".esphome_extracted").is_file(): continue - archive_name = f"{name}-{version}" - if archive_name in seen: + archive = _archive_path(downloads_dir, name, version) + if archive in seen: # A duplicate entry would race itself between two workers continue - seen.add(archive_name) + seen.add(archive) try: url, sha256, size = registry_download(name, version) except EsphomeError as err: @@ -207,10 +215,9 @@ def prefetch_packages( continue if not size: continue - archive = downloads_dir / archive_name if archive.is_file() and archive.stat().st_size == size: continue - pending.append(_PendingArchive(name, version, dest, url, sha256, size)) + pending.append(_PendingArchive(name, version, dest, archive, url, sha256, size)) if len(pending) < 2: return downloads_dir.mkdir(parents=True, exist_ok=True) @@ -222,20 +229,36 @@ def prefetch_packages( def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None: entry.dest.parent.mkdir(parents=True, exist_ok=True) - with FileLock(f"{entry.dest}.lock", fallback_to_soft=False): - # Marker re-check: a concurrent build may have installed (and - # deleted the archive of) this package while we waited; - # re-downloading would orphan a fresh copy in downloads_dir - # no branch: the thread tracer misses the skip edge; both - # arms of _already_installed are pinned directly - if not _already_installed(entry.dest): # pragma: no branch - download_with_resume( - entry.url, - downloads_dir / f"{entry.name}-{entry.version}", - sha256=entry.sha256, - size=entry.size, - progress=tracker, - ) + + def on_disk() -> int: + if done := downloaded_bytes(entry.archive, entry.size): + return done + # The holder deletes the archive once it has installed it + return entry.size if _already_installed(entry.dest) else 0 + + lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False) + try: + wait_for_download_lock(lock, tracker, on_disk, entry.name) + except Timeout: + # install_package waits on this same lock and verifies the + # holder's copy + _LOGGER.debug("Leaving %s to its current downloader", entry.name) + return + try: + if _already_installed(entry.dest): + # A concurrent build installed it while we waited; a + # re-download would orphan a fresh copy in downloads_dir + tracker(entry.size) + return + download_with_resume( + entry.url, + entry.archive, + sha256=entry.sha256, + size=entry.size, + progress=tracker, + ) + finally: + lock.release() failures = run_batch_downloads( "Downloading packages", @@ -288,7 +311,7 @@ def install_package( rmdir(dest, msg=f"Clean up incomplete {name} install") # Persistent location so an interrupted download resumes across runs. downloads_dir.mkdir(parents=True, exist_ok=True) - archive = downloads_dir / f"{name}-{version}" + archive = _archive_path(downloads_dir, name, version) _LOGGER.info("Downloading %s %s ...", name, version) if mirrors: _LOGGER.warning( diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 9de8f715efc..ad9c0bb11fc 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -9,7 +9,7 @@ not be part of a unit test suite. """ -from collections.abc import Generator +from collections.abc import Callable, Generator import os from pathlib import Path import sys @@ -137,3 +137,40 @@ def mock_get_component() -> Generator[Mock, None, None]: """Mock get_component for config module.""" with patch("esphome.config.get_component") as mock: yield mock + + +@pytest.fixture +def held_lock() -> Callable[..., Callable[..., None]]: + """Factory for a ``FileLock.acquire`` fake held by another downloader. + + Each poll writes the next chunk to ``part`` (or runs it, for a callable) + and raises ``Timeout``; when the chunks run out the part is removed, + ``land()`` runs, and the acquire succeeds (also for any later job, so + ``land`` must be idempotent). + """ + from filelock import Timeout + + def make( + part: Path, + chunks: list[bytes | Callable[[], None]], + land: Callable[[], None], + ) -> Callable[..., None]: + polls = iter(chunks) + + def acquire(*args, **kwargs) -> None: + try: + chunk = next(polls) + except StopIteration: + part.unlink(missing_ok=True) + land() + return + if callable(chunk): + chunk() + else: + part.parent.mkdir(parents=True, exist_ok=True) + part.write_bytes(chunk) + raise Timeout("held") + + return acquire + + return make diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index fcc5572f511..22b34c9df51 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -2353,3 +2353,20 @@ def test_discard_partial_download_logs_undeletable( ): framework_helpers.discard_partial_download(dest) assert "Could not remove" in caplog.text + + +def test_downloaded_bytes_reports_what_is_on_disk(tmp_path: Path) -> None: + """Part file first, then the landed file, both capped at size; else 0.""" + dest = tmp_path / "archive" + assert framework_helpers.downloaded_bytes(dest, 4) == 0 + part = tmp_path / "archive.part" + part.write_bytes(b"ab") + assert framework_helpers.downloaded_bytes(dest, 4) == 2 + part.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 + part.unlink() + dest.write_bytes(b"abc") + assert framework_helpers.downloaded_bytes(dest, 4) == 3 + assert framework_helpers.downloaded_bytes(dest) == 3 + dest.write_bytes(b"abcdef") + assert framework_helpers.downloaded_bytes(dest, 4) == 4 diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index fb79885736d..77490fd8613 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -454,23 +454,96 @@ def test_uri_fetch_job_waits_out_a_briefly_held_lock(tmp_path: Path) -> None: assert dl_path.read_bytes() == b"data" -def test_lock_deadline_leaves_download_to_the_holder(tmp_path: Path) -> None: - """A lock held past the deadline means another process is fetching the - same file; skipping cleanly beats a misleading failure warning. The - tracker is still polled so a parked worker observes cancellation.""" +@pytest.mark.parametrize("staged", [b"", b"ab"]) +def test_lock_deadline_leaves_download_to_the_holder( + tmp_path: Path, staged: bytes +) -> None: + """A lock held past the deadline is another process's download; skip + cleanly, polling the tracker with what the holder has staged so far.""" dl_path = tmp_path / "archive" + (tmp_path / "archive.prefetch.part").write_bytes(staged) ticks: list[int] = [] with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) mock_download.assert_not_called() - assert ticks == [0] + assert ticks == [len(staged)] assert not dl_path.exists() +@pytest.mark.parametrize( + ("job", "part_name", "chunks", "expected"), + [ + ( + lambda dl_path: pf._registry_fetch_job( + MagicMock(), "https://x/a.tar.gz", dl_path, "ab" * 32, 4 + ), + "archive.part", + [b"a", b"abc"], + [1, 3, 4], + ), + ( + lambda dl_path: pf._uri_fetch_job( + MagicMock(), "https://x/a.zip", dl_path, 4 + ), + "archive.prefetch.part", + [b"ab"], + [2, 4], + ), + ], + ids=["registry", "uri"], +) +def test_lock_wait_reports_the_holders_progress( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + held_lock, + job, + part_name: str, + chunks: list[bytes], + expected: list[int], +) -> None: + """A waiting job reports the holder's part file (the staging one for a + URL job), then the full size once the holder lands the archive.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / part_name, chunks, lambda: dl_path.write_bytes(b"abcd") + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + caplog.at_level(logging.INFO), + ): + job(dl_path)(ticks.append) + mock_download.assert_not_called() + assert ticks == expected + assert caplog.text.count("Waiting for another process downloading archive") == 1 + + +def test_uri_lock_wait_prefers_the_landed_archive(tmp_path: Path, held_lock) -> None: + """Between the holder's promotion rename and its release the staging + part is gone; the landed cache file is credited instead of 0.""" + dl_path = tmp_path / "archive" + ticks: list[int] = [] + acquire = held_lock( + tmp_path / "archive.prefetch.part", + [b"ab", lambda: dl_path.write_bytes(b"abcd")], + lambda: None, + ) + with ( + patch("esphome.framework_helpers.download_with_resume") as mock_download, + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + ): + pf._uri_fetch_job(MagicMock(), "https://x/a.zip", dl_path, 4)(ticks.append) + mock_download.assert_not_called() + assert ticks == [2, 4, 4] + + def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: """A registry job that lost the download race to another process must not stamp a nonexistent archive into pio's usage.db.""" @@ -479,7 +552,7 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: with ( patch("esphome.framework_helpers.download_with_resume") as mock_download, patch("filelock.FileLock.acquire", side_effect=Timeout("held")), - patch.object(pf, "_DOWNLOAD_LOCK_TIMEOUT", 0), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), ): pf._registry_fetch_job(manager, "https://x/a.tar.gz", dl_path, "ab" * 32, 4)( lambda done: None diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 6ba8691c4ec..9d5f6c4ce59 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -8,6 +8,7 @@ import os from pathlib import Path from unittest.mock import MagicMock, patch +from filelock import Timeout import pytest from esphome.core import EsphomeError @@ -540,16 +541,13 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: dest = tmp_path / "a" dest.mkdir() - from contextlib import contextmanager - - @contextmanager - def marker_appears_under_lock(path, **kwargs): + def marker_appears_under_lock(*args, **kwargs): # Simulates the concurrent build finishing while we waited (dest / ".esphome_extracted").touch() - yield with ( - patch("filelock.FileLock", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.acquire", side_effect=marker_appears_under_lock), + patch("filelock.FileLock.release"), patch.object(registry, "download_with_resume") as mock_download, patch.object( registry, "registry_download", side_effect=_resolve_for({"a": 10}) @@ -559,6 +557,69 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: mock_download.assert_not_called() +def test_prefetch_packages_waits_with_the_holders_progress( + tmp_path: Path, held_lock +) -> None: + """A worker parked on another build's lock reports that build's part + file, then the full size once the marker appears.""" + dest = tmp_path / "a" + dest.mkdir() + ticks: list[int] = [] + part = tmp_path / "dl" / "a-1.0.part" + + def installed_and_pruned() -> None: + # install_package touches the marker, then unlinks the archive + (dest / ".esphome_extracted").touch() + part.unlink() + + acquire = held_lock( + part, + [lambda: None, b"abc", installed_and_pruned], + (dest / ".esphome_extracted").touch, + ) + + def fake_batch(header, jobs): + for _name, _size, fetch in jobs: + fetch(ticks.append) + return [] + + with ( + patch("filelock.FileLock.acquire", side_effect=acquire), + patch("filelock.FileLock.release"), + patch.object(registry, "run_batch_downloads", side_effect=fake_batch), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])], + tmp_path / "dl", + ) + assert ticks == [0, 3, 10, 10] + mock_download.assert_called_once() + + +def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder( + tmp_path: Path, +) -> None: + """Past the deadline the worker skips; install_package waits on the same + lock later and verifies whatever the holder produced.""" + with ( + patch("filelock.FileLock.acquire", side_effect=Timeout("held")), + patch("esphome.framework_helpers.DOWNLOAD_LOCK_TIMEOUT", 0), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 5}) + ), + ): + registry.prefetch_packages( + [("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + def test_already_installed_probe(tmp_path: Path) -> None: """Both arms of the marker probe the prefetch worker keys on.""" dest = tmp_path / "pkg" From 8434dc5474e433a61a250800b489674ec5116d84 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:29:20 +1200 Subject: [PATCH 019/759] [esp32_hosted] Add ESP-NOW-over-hosted shim for the ESP32-P4 (#17712) --- esphome/components/esp32_hosted/__init__.py | 36 ++ .../esp32_hosted/esp_now_hosted.cpp | 467 ++++++++++++++++++ .../esp32_hosted/esp_now_hosted_rpc.h | 128 +++++ esphome/components/espnow/__init__.py | 20 + esphome/core/defines.h | 1 + script/ci-custom.py | 17 +- .../test-espnow.esp32-p4-idf.yaml | 5 + tests/unit_tests/components/test_espnow.py | 48 ++ 8 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 esphome/components/esp32_hosted/esp_now_hosted.cpp create mode 100644 esphome/components/esp32_hosted/esp_now_hosted_rpc.h create mode 100644 tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml create mode 100644 tests/unit_tests/components/test_espnow.py diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index ab9455250cd..21626e432b6 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -37,6 +37,25 @@ CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" CONF_SPI_MODE = "spi_mode" +# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h +# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects +# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the +# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES +# those symbols and forwards each call to the co-processor over esp-hosted's +# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links +# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to +# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native +# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host +# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ); +# we ask for 8 to leave room for other CustomRpc extensions alongside. +# +# The coprocessor must run the matching custom firmware (a parallel effort in +# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy +# of the wire contract and MUST stay byte-identical to the copy that coprocessor +# firmware uses — the packed structs are the on-wire layout, so any divergence +# silently corrupts every ESP-NOW frame. +_MAX_CUSTOM_MSG_HANDLERS = 8 + # Shared fields for both transport modes BASE_SCHEMA = cv.Schema( { @@ -262,6 +281,23 @@ async def to_code(config: ConfigType) -> None: else: _configure_spi(config) + # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see + # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not + # gated on the `espnow` component being present: the shim is tiny and the + # esp_now_* symbols/CustomRpc calls it defines require these Kconfig options + # to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so + # coupling the two keeps the build consistent. When `espnow` is absent the + # symbols are simply unused and never register a callback at runtime. + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + add_define("USE_ESP_NOW_HOSTED") + # esp-hosted's CustomRpc ("peer data transfer") path — off by default. + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS + ) + # Place the transport mempool in PSRAM. Required on memory-tight host # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM # mempool allocation fails at boot with `sdio_mempool_create` assert. diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp new file mode 100644 index 00000000000..ad29b208feb --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -0,0 +1,467 @@ +/* + * esp_now_hosted — host-side shim implementing over esp-hosted + * CustomRpc, so ESPHome's `espnow` component can run on a radio-less host + * (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor. + * + * A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full + * esp_now.h header (types + declarations) but ships NO implementation, so every + * esp_now_* symbol is an undefined reference at link time. This translation + * unit provides those definitions; each forwards to the co-processor over + * CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor + * handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no + * duplicate-symbol clash because nothing else defines these symbols here. + * + * See esp_now_hosted_rpc.h for the wire protocol. + */ + +#include "sdkconfig.h" + +// Only build the shim on the radio-less host. On chips with a native ESP-NOW +// stack (S3, C6, …) the real symbols exist and this file must stay empty to +// avoid duplicate definitions. +#if defined(CONFIG_IDF_TARGET_ESP32P4) + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_idf_version.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include // injected declarations we are now DEFINING +#include // wifi_pkt_rx_ctrl_t, wifi_tx_info_t + +// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it +// from C++ would give its declarations C++ linkage and the real C symbols in +// libesp_hosted would go unresolved at link. Wrap it. (Verified vs +// esp_hosted 2.12.9.) +extern "C" { +#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback} +} + +#include "esp_now_hosted_rpc.h" + +namespace { + +const char *const TAG = "esp_now_hosted"; + +// One outstanding request at a time. ESPHome drives esp_now_* from the main +// loop; the matching response and the async RECV/SEND events all arrive on the +// single esp-hosted RPC RX thread. Serializing requests keeps the shared +// response slot race-free; a sequence number stops a late/stale response from +// being mistaken for ours. +SemaphoreHandle_t g_req_mutex = nullptr; +SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands +bool g_setup_done = false; // set only after setup fully succeeds +uint8_t g_seq = 0; +volatile uint8_t g_expect_seq = 0; +volatile int32_t g_resp_status = 0; +uint8_t g_resp_ret[16]; +volatile uint16_t g_resp_ret_len = 0; + +// Written from the main loop (register/unregister/deinit), read from the +// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the +// g_resp_* globals are: force the RX thread to observe an updated pointer +// (e.g. a nulling by esp_now_deinit) rather than a cached one. +volatile esp_now_recv_cb_t g_recv_cb = nullptr; +volatile esp_now_send_cb_t g_send_cb = nullptr; + +// Local mirror of the co-processor's peer table. ESPHome's espnow component +// calls esp_now_is_peer_exist() on the main loop for every received frame +// (twice) and every send; forwarding each as a blocking RPC round-trip stalls +// the loop. The shim is the only path that mutates the co-processor peer table +// (add/del/deinit all go through here), so this mirror is authoritative and +// esp_now_is_peer_exist() can answer from it with no round-trip. +// +// esp_now_* are public C symbols: any component or user lambda may call them, +// and although ESPHome's espnow touches peers only from the main loop today +// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short +// spinlock keeps the mirror consistent from any task/core, matching native +// esp_now_*'s own internal thread-safety. The critical sections are a bounded +// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20. +constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; +uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; +size_t g_peer_count = 0; +portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; + +// Caller must hold g_peer_lock. +int peer_cache_find_locked(const uint8_t *mac) { + for (size_t i = 0; i < g_peer_count; i++) { + if (memcmp(g_peer_cache[i], mac, 6) == 0) + return static_cast(i); + } + return -1; +} + +bool peer_cache_contains(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const bool found = peer_cache_find_locked(mac) >= 0; + portEXIT_CRITICAL(&g_peer_lock); + return found; +} + +void peer_cache_add(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS) + memcpy(g_peer_cache[g_peer_count++], mac, 6); + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_remove(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const int idx = peer_cache_find_locked(mac); + if (idx >= 0) { + g_peer_count--; + if (static_cast(idx) != g_peer_count) // move the last entry into the gap + memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6); + } + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_clear() { + portENTER_CRITICAL(&g_peer_lock); + g_peer_count = 0; + portEXIT_CRITICAL(&g_peer_lock); +} + +// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ────────── +// Keep them short and non-blocking. In particular they MUST NOT call back into +// any esp_now_* shim function: that would try to take g_req_mutex / wait on the +// RX thread that delivers the response, and deadlock. + +void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + if (len < sizeof(esp_now_hosted_resp_t)) { + ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast(len)); + return; + } + const auto *r = reinterpret_cast(data); + if (r->seq != g_expect_seq) { // late response from a timed-out request (expected) + ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq); + return; + } + g_resp_status = r->status; + uint16_t rl = r->ret_len; + if (rl > sizeof(g_resp_ret)) { + // Larger than any real opcode return — a likely wire-format drift signal. + ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl); + rl = sizeof(g_resp_ret); + } + if (len >= sizeof(esp_now_hosted_resp_t) + rl) { + memcpy(g_resp_ret, r->ret, rl); + } else { + // Truncated frame: fail closed. Never hand the caller stale bytes left in + // g_resp_ret by a previous response, and don't let request() report a + // zeroed payload as success — override the status to an error. + ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl); + rl = 0; + g_resp_status = ESP_ERR_INVALID_RESPONSE; + } + g_resp_ret_len = rl; + xSemaphoreGive(g_resp_sem); +} + +void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via + // the espnow component's disable()) can null it on the main loop between the + // guard and the call, which would otherwise turn the call into a null-deref. + const esp_now_recv_cb_t cb = g_recv_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_recv_evt_t)) { + ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); + if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) { + ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len); + return; + } + + // ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one. + wifi_pkt_rx_ctrl_t rx_ctrl; + memset(&rx_ctrl, 0, sizeof(rx_ctrl)); + rx_ctrl.rssi = e->rssi; + rx_ctrl.channel = e->channel; + rx_ctrl.timestamp = static_cast(esp_timer_get_time()); + + esp_now_recv_info_t info; + info.src_addr = const_cast(e->src_addr); + info.des_addr = const_cast(e->des_addr); + info.rx_ctrl = &rx_ctrl; + cb(&info, e->data, static_cast(e->data_len)); +} + +void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once (see on_recv): disable()/deinit() can null it + // on the main loop concurrently with this RX-thread callback. + const esp_now_send_cb_t cb = g_send_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_send_evt_t)) { + ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t), + // whose des_addr is a POINTER (not an inline array). Point it at the event's + // MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and + // faults). ESPHome reads only info->des_addr. + esp_now_send_info_t si; + memset(&si, 0, sizeof(si)); + si.des_addr = const_cast(e->des_addr); + cb(&si, static_cast(e->status)); +#else + cb(e->des_addr, static_cast(e->status)); +#endif +} + +esp_err_t ensure_setup() { + // Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a + // semaphore that did not allocate, a callback that did not register) must not + // leave a later call thinking setup completed. Semaphore creation is guarded + // so a retry after a partial failure does not leak the earlier handles. + if (g_setup_done) + return ESP_OK; + if (g_req_mutex == nullptr) + g_req_mutex = xSemaphoreCreateMutex(); + if (g_resp_sem == nullptr) + g_resp_sem = xSemaphoreCreateBinary(); + if (g_req_mutex == nullptr || g_resp_sem == nullptr) + return ESP_ERR_NO_MEM; + esp_err_t err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + return err; + g_setup_done = true; + return ESP_OK; +} + +// Send one request envelope. With wait=true (default) block until the matching +// response (or timeout); with wait=false return as soon as the frame is handed +// to the transport (fire-and-forget, used by esp_now_send). +// +// `tail` is an optional second chunk written straight after `payload`. Callers +// with a fixed header plus a bulk body (esp_now_send) pass the two separately +// so they never need a build buffer of their own: both chunks are laid into the +// request buffer here, under g_req_mutex, which keeps concurrent callers from +// racing and saves a full copy of the body on every transmit. +esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len, + bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen) + return ESP_ERR_INVALID_SIZE; + const uint16_t total_len = static_cast(plen + tail_len); + + if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE) + return ESP_FAIL; + + static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex + auto *req = reinterpret_cast(buf); + req->opcode = opcode; + req->seq = ++g_seq; + req->payload_len = total_len; + if (plen != 0) + memcpy(req->payload, payload, plen); + if (tail_len != 0) + memcpy(req->payload + plen, tail, tail_len); + g_expect_seq = req->seq; + + xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending + err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len); + if (err != ESP_OK) { + xSemaphoreGive(g_req_mutex); + return err; + } + if (!wait) { + // Fire-and-forget (esp_now_send): the co-processor enqueues the frame and + // reports the real TX result later via the async SEND event, exactly like + // native esp_now_send. Returning here keeps the main loop off the ~100 ms+ + // RPC round-trip. The matching RESP is ignored (seq won't match the next + // waited request, so on_resp drops it). + xSemaphoreGive(g_req_mutex); + return ESP_OK; + } + if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "opcode %u timed out", opcode); + xSemaphoreGive(g_req_mutex); + return ESP_ERR_TIMEOUT; + } + + const int32_t status = g_resp_status; + if (ret != nullptr && ret_cap != 0) { + uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap; + memcpy(ret, const_cast(g_resp_ret), n); + if (ret_len != nullptr) + *ret_len = n; + } + xSemaphoreGive(g_req_mutex); + return static_cast(status); +} + +} // namespace + +// ── The surface, defined for the radio-less host ──────────────── +extern "C" { + +esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); } + +esp_err_t esp_now_deinit(void) { + g_recv_cb = nullptr; + g_send_cb = nullptr; + peer_cache_clear(); // the co-processor drops all peers on deinit + return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr); +} + +esp_err_t esp_now_get_version(uint32_t *version) { + uint32_t v = 0; + uint16_t rl = 0; + esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl); + if (version != nullptr) + *version = v; + return err; +} + +esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) { + // Only arm the callback once the CustomRpc handlers are actually registered, + // so a failed setup leaves g_recv_cb null rather than falsely "registered". + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_recv_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_recv_cb(void) { + g_recv_cb = nullptr; + return ESP_OK; +} +esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_send_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_send_cb(void) { + g_send_cb = nullptr; + return ESP_OK; +} + +static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) { + if (peer == nullptr) + return ESP_ERR_ESPNOW_ARG; + esp_now_hosted_peer_t p; + memset(&p, 0, sizeof(p)); + memcpy(p.peer_addr, peer->peer_addr, 6); + memcpy(p.lmk, peer->lmk, 16); + p.channel = peer->channel; + p.ifidx = static_cast(peer->ifidx); + p.encrypt = peer->encrypt ? 1 : 0; + return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait); +} +esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) { + // Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip, + // and ESPHome's espnow calls it on the main loop when a device joins the mesh + // — under co-processor load that stalls the UI (peer-churn stutter). Issue it + // without waiting and mirror it locally. Safe against a following + // esp_now_send to the same peer: both ride the same in-order CustomRpc + // channel (mutex-serialized on the host) and the co-processor processes REQs + // FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side + // failure (e.g. peer table full) is no longer reported synchronously — the + // same limitation as esp_now_send — but ESPHome only adds peers it validated. + esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false); + if (err == ESP_OK) + peer_cache_add(peer->peer_addr); // keep the local mirror in sync + return err; +} +esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) { + // mod_peer changes a peer's parameters, not its existence, so the cache is + // unaffected. Kept synchronous — it is not on any hot path (espnow never + // calls it), so the extra round-trip does not matter and the status is useful. + return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true); +} + +esp_err_t esp_now_del_peer(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return ESP_ERR_ESPNOW_ARG; + // Fire-and-forget for the same reason as add_peer (peer churn on the main + // loop). Removal is order-independent, so this is strictly safe. + esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false); + if (err == ESP_OK) + peer_cache_remove(peer_addr); // keep the local mirror in sync + return err; +} + +bool esp_now_is_peer_exist(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return false; + // Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls + // this on the main loop for every received frame and every send, so a + // blocking round-trip here would stall rendering under mesh traffic. + return peer_cache_contains(peer_addr); +} + +esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) { + if (len > ESP_NOW_HOSTED_MAX_FRAME) + return ESP_ERR_ESPNOW_ARG; + if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error + return ESP_ERR_ESPNOW_ARG; + // Only the small fixed header is built here; the caller's frame goes over as + // the request tail, so request() lays both into its own buffer under + // g_req_mutex. esp_now_send is a public C symbol and may be called from any + // task, and a shared build buffer here would let two callers corrupt each + // other's frame. Passing the body through also drops a full-frame copy per + // transmit, on the path this shim exists to keep quick. + uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; + auto *s = reinterpret_cast(hdr); + s->has_addr = peer_addr != nullptr ? 1 : 0; + if (peer_addr != nullptr) + memcpy(s->peer_addr, peer_addr, 6); + else + memset(s->peer_addr, 0, 6); + s->data_len = static_cast(len); + // Fire-and-forget (wait=false): native esp_now_send returns once the frame is + // queued, with the real TX result delivered later through the send callback. + // The co-processor mirrors that — it acks enqueue immediately and reports the + // outcome via the async SEND event (on_send -> on_send_report). Waiting for + // the RPC RESP here would block the main loop for the full round-trip on + // every transmit. + return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data, + static_cast(len)); +} + +esp_err_t esp_now_set_pmk(const uint8_t *pmk) { + if (pmk == nullptr) + return ESP_ERR_ESPNOW_ARG; + return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr); +} + +// Remainder of the surface. Not used by ESPHome's espnow component +// today; provided so the whole header links and future callers get a defined +// (if unimplemented) symbol rather than a link error. Wire them through +// CustomRpc if a use case appears. +esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_set_wake_window(uint16_t /*window*/) { + return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success +} +esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) { + return ESP_ERR_NOT_SUPPORTED; +} + +} // extern "C" + +#endif // CONFIG_IDF_TARGET_ESP32P4 diff --git a/esphome/components/esp32_hosted/esp_now_hosted_rpc.h b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h new file mode 100644 index 00000000000..bf68c759ee9 --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h @@ -0,0 +1,128 @@ +/* + * esp_now_hosted — ESP-NOW-over-CustomRpc wire protocol. + * + * Shared, byte-for-byte-identical contract between: + * - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp) + * - the coprocessor firmware (esphome/esp-hosted-firmware) + * + * It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer", + * available since esp-hosted v2.8.1), teaching the radio-less host <-> radio + * co-processor link to carry esp_now.h, which esp-hosted itself does not proxy + * (Espressif issue espressif/esp-hosted-mcu#19). + * + * KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor + * firmware uses a verbatim copy. Both sides are little-endian, so these packed + * structs are wire-compatible with no byte-swapping. + */ + +#ifndef ESP_NOW_HOSTED_RPC_H +#define ESP_NOW_HOSTED_RPC_H + +#ifdef __cplusplus +#include +#else +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ────────────────── + * One REQ handler slot on the device; three event handler slots on the host. + * The bytes spell "now" + index, a private range unlikely to clash with other + * CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */ +#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */ +#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */ +#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */ +#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */ + +/* ── Request opcodes ────────────────────────────────────────────────────── */ +enum { + ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */ + ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */ + ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */ + ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */ + ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */ + ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */ + ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */ + ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */ + ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */ +}; + +/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well + * under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */ +#define ESP_NOW_HOSTED_MAX_FRAME 1470u +/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */ +#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u) +/* Host request/response round-trip timeout over the transport. Generous: + * normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor + * can stall the RX thread. */ +#define ESP_NOW_HOSTED_TIMEOUT_MS 2000 + +/* ── Envelopes ──────────────────────────────────────────────────────────── */ + +/* These payloads are shared verbatim with the C co-processor firmware, so they + * use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases, + * which would not compile there. Silence clang-tidy's modernize-use-using for + * the shared struct block. */ +// NOLINTBEGIN(modernize-use-using) +typedef struct { + uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */ + uint8_t seq; /* wraps 0..255; echoed in the response for matching */ + uint16_t payload_len; /* bytes of opcode-specific payload that follow */ + uint8_t payload[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_req_t; + +typedef struct { + uint8_t opcode; /* echoes the request opcode */ + uint8_t seq; /* echoes the request seq */ + int32_t status; /* esp_err_t from the native call on the co-processor */ + uint16_t ret_len; /* bytes of return payload that follow */ + uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */ +} __attribute__((packed)) esp_now_hosted_resp_t; + +/* ── Opcode payloads ────────────────────────────────────────────────────── */ + +/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless + * across the transport and never set by ESPHome's espnow component. */ +typedef struct { + uint8_t peer_addr[6]; + uint8_t lmk[16]; + uint8_t channel; /* 0 = current channel */ + uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */ + uint8_t encrypt; /* bool */ +} __attribute__((packed)) esp_now_hosted_peer_t; + +typedef struct { + uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */ + uint8_t peer_addr[6]; + uint16_t data_len; + uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */ +} __attribute__((packed)) esp_now_hosted_send_req_t; + +/* ── Async events (device -> host) ──────────────────────────────────────── */ + +/* Reconstructed on the host into an esp_now_recv_info_t + a minimal + * wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr, + * info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */ +typedef struct { + uint8_t src_addr[6]; + uint8_t des_addr[6]; + int8_t rssi; + uint8_t channel; + uint16_t data_len; + uint8_t data[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_recv_evt_t; + +typedef struct { + uint8_t des_addr[6]; + uint8_t status; /* esp_now_send_status_t (0 = success) */ +} __attribute__((packed)) esp_now_hosted_send_evt_t; +// NOLINTEND(modernize-use-using) + +#ifdef __cplusplus +} +#endif + +#endif /* ESP_NOW_HOSTED_RPC_H */ diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 5541a6ee97d..14d099ec062 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.udp import CONF_ON_RECEIVE import esphome.config_validation as cv from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -132,6 +134,24 @@ CONFIG_SCHEMA = cv.All( ) +def _validate_variant(config: ConfigType) -> ConfigType: + # ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native + # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that + # supplies the esp_now_* symbols. Fail here with a clear message instead of + # letting the build reach an "undefined reference to esp_now_*" link error. + variant = get_esp32_variant() + if wifi.variant_has_wifi(variant): + return config + if variant != VARIANT_ESP32P4: + raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)") + if "esp32_hosted" not in fv.full_config.get(): + raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component") + return config + + +FINAL_VALIDATE_SCHEMA = _validate_variant + + async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9dd1e0ced64..eaece6d5ffa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -71,6 +71,7 @@ #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP32_IMPROV_STATE_CALLBACK +#define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN #define USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/script/ci-custom.py b/script/ci-custom.py index f481fda8609..e2b7cd8d376 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -294,6 +294,9 @@ def highlight(s): "esphome/components/socket/headers.h", "esphome/core/defines.h", "esphome/components/http_request/httplib.h", + # Shared C wire header (byte-identical with the co-processor firmware); + # these are protocol constants and constexpr is C++-only. + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", ], ) def lint_no_defines(fname, match): @@ -816,6 +819,10 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", "esphome/components/http_request/httplib.h", + # Global extern "C" esp_now_* linker symbols + shared C wire header; + # neither can live in a C++ namespace. + "esphome/components/esp32_hosted/esp_now_hosted.cpp", + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", ], ) def lint_namespace(fname: Path, content: str) -> str | None: @@ -841,7 +848,15 @@ def lint_esphome_h(fname, line, col, content): ) -@lint_content_check(include=["*.h"], exclude=["esphome/core/entity_types.h"]) +@lint_content_check( + include=["*.h"], + exclude=[ + "esphome/core/entity_types.h", + # Shared C wire header; uses a classic #ifndef guard for portability + # across the co-processor firmware repo it stays byte-identical with. + "esphome/components/esp32_hosted/esp_now_hosted_rpc.h", + ], +) def lint_pragma_once(fname, content): if "#pragma once" not in content: return ( diff --git a/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml new file mode 100644 index 00000000000..fab0a64ab84 --- /dev/null +++ b/tests/components/esp32_hosted/test-espnow.esp32-p4-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the ESP-NOW-over-hosted shim: on the ESP32-P4 host, esp32_hosted +# supplies the esp_now_* symbols that the espnow component links against. +packages: + esp32_hosted: !include common.yaml + espnow: !include ../espnow/common.yaml diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py new file mode 100644 index 00000000000..21305c2b332 --- /dev/null +++ b/tests/unit_tests/components/test_espnow.py @@ -0,0 +1,48 @@ +"""Tests for the espnow component's final validation.""" + +import pytest + +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +) +from esphome.components.espnow import _validate_variant +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _run( + monkeypatch, variant: str, full_config: dict, config: ConfigType +) -> ConfigType: + monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + token = fv.full_config.set(full_config) + try: + return _validate_variant(config) + finally: + fv.full_config.reset(token) + + +def test_variant_with_native_wifi_passes(monkeypatch) -> None: + """A variant with a native Wi-Fi PHY needs no shim; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config + + +def test_radioless_non_p4_variant_rejected(monkeypatch) -> None: + """Radio-less variants without any ESP-NOW path are rejected outright.""" + with pytest.raises(cv.Invalid, match="not supported"): + _run(monkeypatch, VARIANT_ESP32H2, {}, {}) + + +def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None: + """The P4 needs the esp32_hosted shim to supply the esp_now_* symbols.""" + with pytest.raises(cv.Invalid, match="esp32_hosted"): + _run(monkeypatch, VARIANT_ESP32P4, {}, {}) + + +def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: + """The P4 with esp32_hosted present validates; config passes through.""" + config = {"id": "espnow"} + assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config From 9ba4477ada0f207b7426fe83b37fde69c9ed9947 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:46:22 +1200 Subject: [PATCH 020/759] Bump version to 2026.9.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7b2d21027a8..060de51d3af 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b1 +PROJECT_NUMBER = 2026.9.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 378da14197c..287804ace3b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b1" +__version__ = "2026.9.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 5e37872da24f4626dc7ea8bdd61f4c5534f6c0ee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:32:18 +1200 Subject: [PATCH 021/759] [ci] Sync pre-commit revs and prek version from requirements files (#19026) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 13 +- .../workflows/sync-dependency-versions.yml | 94 ++++++++ .pre-commit-config.yaml | 5 +- AGENTS.md | 2 +- requirements_dev.txt | 4 +- requirements_test.txt | 9 +- script/sync_dependency_versions.py | 164 +++++++++++++ tests/script/test_sync_dependency_versions.py | 219 ++++++++++++++++++ 8 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/sync-dependency-versions.yml create mode 100755 script/sync_dependency_versions.py create mode 100644 tests/script/test_sync_dependency_versions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c93b3b867..173d2c227ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,11 +244,20 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Read prek version from requirements_test.txt + id: prek + # requirements_test.txt is the only place the version is pinned, so a + # Dependabot bump there is picked up here without a second edit. + run: | + if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then + echo "::error::No prek== pin found in requirements_test.txt." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - # Keep in sync with requirements_test.txt. - prek-version: "0.4.11" + prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates # the cache on dev. Every run would miss and then write a per-pull # request copy, which is what the old seed-cache job existed to diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml new file mode 100644 index 00000000000..5599691ed19 --- /dev/null +++ b/.github/workflows/sync-dependency-versions.yml @@ -0,0 +1,94 @@ +# Keeps pre-commit hook revs in sync with the requirements files. +# +# Dependabot only bumps the pins in requirements*.txt. Some of those tools +# are pinned again as hook revs in .pre-commit-config.yaml. This workflow +# runs script/sync_dependency_versions.py against the pull request branch +# and pushes a commit with the revs updated. + +name: Sync dependency versions + +on: + # pull_request_target rather than pull_request so the App secret is + # available on Dependabot pull requests (pull_request runs opened by + # Dependabot only see Dependabot secrets). The job below only touches + # branches in this repository and only ever executes the script from the + # base branch checkout, so fork code never runs with the token. + pull_request_target: + types: [opened, synchronize, reopened] + paths: + - requirements_dev.txt + - requirements_test.txt + - .pre-commit-config.yaml + - script/sync_dependency_versions.py + +# The push to the pull request branch uses the App token minted below, so +# the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync pinned versions + runs-on: ubuntu-latest + # Same-repository branches only: a push to a fork is not possible with + # this token, and it keeps untrusted heads out of a privileged job. + if: >- + github.repository == 'esphome/esphome' + && github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # A push made with the workflow's own GITHUB_TOKEN would not start + # CI on the new commit; a push with the App token does. + permission-contents: write # git push of the sync commit to the pull request branch + + - name: Check out base branch + # Provides the script that runs below. Deliberately the base branch + # so the pull request cannot change what executes here. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Check out pull request branch + # No allow-unsafe-pr-checkout here on purpose: checkout v7 only + # refuses heads that live in a different repository, and the job + # condition above already limits runs to same-repository branches. + # Leaving it off keeps that refusal as a backstop for fork heads. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + path: pull-request + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install yamlrocks + # The script edits YAML through yamlrocks. Take the pin from the + # base branch requirements so this workflow has no copy of its own. + run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)" + + - name: Sync pinned versions + run: python script/sync_dependency_versions.py --root pull-request + + - name: Push changes + working-directory: pull-request + run: | + if git diff --quiet; then + echo "All pinned versions already match the requirements files." + exit 0 + fi + git config user.name "esphome[bot]" + git config user.email "115708604+esphome[bot]@users.noreply.github.com" + git commit -am "Sync pinned tool versions with requirements files" + git push diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ea799aa4de..1af0e19273a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks - ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit @@ -11,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.3 + rev: v0.16.5 hooks: # Run the linter. - id: ruff @@ -42,7 +41,7 @@ repos: - id: pyupgrade args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint exclude: ^(\.clang-format|\.clang-tidy)$ diff --git a/AGENTS.md b/AGENTS.md index 15b92c4deb7..98bdd58ec52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -840,7 +840,7 @@ file does, and it is the authority when they disagree. The most useful starting cv.rename_key( CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" ), - cv.Schema({ ... }), + cv.Schema({...}), ) ``` For other deprecations, warn manually during validation: diff --git a/requirements_dev.txt b/requirements_dev.txt index f2cf855d6b5..ee94a2401ae 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment -clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating +clang-format==13.0.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py clang-tidy==22.1.8 -yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating +yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py diff --git a/requirements_test.txt b/requirements_test.txt index 897445a4cbc..ef70a5ac0c0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,9 @@ pylint==4.0.8 -flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.5 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating -prek==0.5.1 # also change in .github/workflows/ci.yml when updating +flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.5 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +prek==0.5.1 # .github/workflows/ci.yml reads this pin +yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests pytest==9.1.1 diff --git a/script/sync_dependency_versions.py b/script/sync_dependency_versions.py new file mode 100755 index 00000000000..a97a58b3b0e --- /dev/null +++ b/script/sync_dependency_versions.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Keep pre-commit hook revs in sync with the requirements files. + +Dependabot only bumps the ``package==version`` pins in ``requirements*.txt``. +Some of those tools are pinned a second time as hook ``rev`` values in +``.pre-commit-config.yaml``. This script treats the requirements files as +the source of truth and rewrites the revs to match, editing the config +through yamlrocks so comments and layout survive. + +Run without arguments to apply the changes in place, or with ``--check`` to +only report drift (exit status 1 when anything is out of sync). +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys +from typing import Any + +import yamlrocks + +REPO_ROOT = Path(__file__).resolve().parent.parent +PRECOMMIT_CONFIG = ".pre-commit-config.yaml" + + +class SyncError(Exception): + """A pin could not be located in a requirements file or the config.""" + + +@dataclass(frozen=True) +class SyncTarget: + """A requirements pin and the pre-commit repo whose rev mirrors it.""" + + package: str + requirements_file: str + repo: str + + +SYNC_TARGETS: tuple[SyncTarget, ...] = ( + SyncTarget( + "ruff", "requirements_test.txt", "https://github.com/astral-sh/ruff-pre-commit" + ), + SyncTarget("flake8", "requirements_test.txt", "https://github.com/PyCQA/flake8"), + SyncTarget( + "pyupgrade", "requirements_test.txt", "https://github.com/asottile/pyupgrade" + ), + SyncTarget( + "clang-format", + "requirements_dev.txt", + "https://github.com/pre-commit/mirrors-clang-format", + ), + SyncTarget( + "yamllint", + "requirements_dev.txt", + "https://github.com/adrienverge/yamllint.git", + ), +) + + +def read_requirement_version(requirements: str, package: str) -> str | None: + """Return the ``==`` pin for ``package`` or None when it is not pinned.""" + pattern = re.compile( + rf"^{re.escape(package)}==(?P[^\s#]+)", + re.MULTILINE | re.IGNORECASE, + ) + match = pattern.search(requirements) + return match.group("version") if match else None + + +def find_repo_entry(doc: Any, repo: str) -> Any: + """Return the single ``- repo:`` block for ``repo`` in a pre-commit doc.""" + try: + entries = [entry for entry in doc["repos"] if entry["repo"] == repo] + except KeyError as err: + raise SyncError(f"malformed pre-commit config, missing key {err}") from None + if len(entries) != 1: + raise SyncError( + f"expected exactly one block for repo {repo}, found {len(entries)}" + ) + return entries[0] + + +def current_rev(entry: Any, repo: str) -> tuple[str, str]: + """Split the block's rev into its tag prefix (``v`` or empty) and version.""" + if "rev" not in entry: + raise SyncError(f"repo {repo} has no rev") + rev = entry["rev"] + if not isinstance(rev, str): + # A rev such as ``1.0`` parses as a number and cannot be compared or + # rewritten safely; quote it in the config instead. + raise SyncError(f"rev of repo {repo} is not a string: {rev!r}") + prefix = "v" if rev.startswith("v") else "" + return prefix, rev.removeprefix("v") + + +def sync(root: Path, *, write: bool) -> list[str]: + """Bring every hook rev in line with its requirements pin. + + Returns one description per rev that was (or, when ``write`` is False, + would be) changed. Raises SyncError when a pin cannot be found, which + means SYNC_TARGETS has gone stale and needs updating by hand. + """ + config_path = root / PRECOMMIT_CONFIG + doc = yamlrocks.loads(config_path.read_bytes(), option=yamlrocks.OPT_ROUND_TRIP) + requirements: dict[str, str] = {} + changes: list[str] = [] + for target in SYNC_TARGETS: + if target.requirements_file not in requirements: + requirements[target.requirements_file] = ( + root / target.requirements_file + ).read_text() + version = read_requirement_version( + requirements[target.requirements_file], target.package + ) + if version is None: + raise SyncError( + f"{target.requirements_file}: no '{target.package}==' pin found" + ) + + entry = find_repo_entry(doc, target.repo) + prefix, current = current_rev(entry, target.repo) + if current == version: + continue + changes.append(f"{target.package}: {current} -> {version}") + entry["rev"] = f"{prefix}{version}" + + if changes and write: + config_path.write_bytes(doc.to_yaml()) + return changes + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="report drift without modifying any file; exit 1 if out of sync", + ) + parser.add_argument( + "--root", + type=Path, + default=REPO_ROOT, + help="repository checkout to operate on (default: this checkout)", + ) + args = parser.parse_args(argv) + + try: + changes = sync(args.root, write=not args.check) + except SyncError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + + for change in changes: + print(change) + if args.check and changes: + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tests/script/test_sync_dependency_versions.py b/tests/script/test_sync_dependency_versions.py new file mode 100644 index 00000000000..787c8112d9d --- /dev/null +++ b/tests/script/test_sync_dependency_versions.py @@ -0,0 +1,219 @@ +"""Unit tests for script/sync_dependency_versions.py.""" + +from pathlib import Path +import subprocess +import sys + +import pytest +import yamlrocks + +sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve())) + +import sync_dependency_versions as sync_mod # noqa: E402 + +PRECOMMIT = """\ +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.0 + hooks: + - id: ruff + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + - repo: https://github.com/asottile/pyupgrade + rev: v3.0.0 + hooks: + - id: pyupgrade + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v13.0.1 + hooks: + - id: clang-format + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.0.0 + hooks: + - id: yamllint + - repo: local + hooks: + - id: pylint +""" + +REQ_TEST = """\ +pylint==4.0.8 +flake8==7.1.0 +ruff==0.2.0 # comment +pyupgrade==3.0.0 +""" + +REQ_DEV = """\ +clang-format==13.0.1 +yamllint==1.0.0 +""" + +RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit" +DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n" + +EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"] +EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace( + "rev: 7.0.0", "rev: 7.1.0" +) + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted.""" + (tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT) + (tmp_path / "requirements_test.txt").write_text(REQ_TEST) + (tmp_path / "requirements_dev.txt").write_text(REQ_DEV) + return tmp_path + + +def _load(text: str) -> object: + return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP) + + +@pytest.mark.parametrize( + ("requirements", "expected"), + [ + ("prek==0.5.1 # comment\n", "0.5.1"), + ("Prek==0.5.1\n", "0.5.1"), + ("other==1.0\nprek==0.5.1\n", "0.5.1"), + ("prek>=0.5.1\n", None), + ("prek-extra==0.5.1\n", None), + ("", None), + ], +) +def test_read_requirement_version(requirements: str, expected: str | None) -> None: + assert sync_mod.read_requirement_version(requirements, "prek") == expected + + +def test_find_repo_entry() -> None: + entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO) + assert entry["rev"] == "v0.1.0" + + +@pytest.mark.parametrize( + ("text", "message"), + [ + ("hooks: []\n", "missing key 'repos'"), + ("repos:\n - rev: 1.0.0\n", "missing key 'repo'"), + (PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"), + ("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"), + ], +) +def test_find_repo_entry_errors(text: str, message: str) -> None: + with pytest.raises(sync_mod.SyncError, match=message): + sync_mod.find_repo_entry(_load(text), RUFF_REPO) + + +@pytest.mark.parametrize( + ("rev", "expected"), + [("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))], +) +def test_current_rev(rev: str, expected: tuple[str, str]) -> None: + doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n") + assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected + + +@pytest.mark.parametrize( + ("block", "message"), + [(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")], +) +def test_current_rev_errors(block: str, message: str) -> None: + doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}") + with pytest.raises(sync_mod.SyncError, match=message): + sync_mod.current_rev(doc["repos"][0], RUFF_REPO) + + +def test_sync_reports_without_writing(root: Path) -> None: + assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT + + +def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None: + assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT + assert sync_mod.sync(root, write=True) == [] + + +def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None: + (root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT) + before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns + assert sync_mod.sync(root, write=True) == [] + assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before + + +def test_sync_missing_requirement_pin(root: Path) -> None: + (root / "requirements_dev.txt").write_text("") + with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"): + sync_mod.sync(root, write=True) + + +def test_sync_propagates_config_errors(root: Path) -> None: + (root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK) + with pytest.raises(sync_mod.SyncError, match="found 2"): + sync_mod.sync(root, write=True) + + +def test_main_check_reports_drift( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert sync_mod.main(["--check", "--root", str(root)]) == 1 + assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT + assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT + + +def test_main_writes_then_check_is_clean( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert sync_mod.main(["--root", str(root)]) == 0 + assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT + assert sync_mod.main(["--check", "--root", str(root)]) == 0 + assert capsys.readouterr().out == "" + + +def test_main_reports_sync_error( + root: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (root / "requirements_dev.txt").write_text("") + assert sync_mod.main(["--root", str(root)]) == 1 + assert ( + "error: requirements_dev.txt: no 'clang-format==' pin" + in capsys.readouterr().err + ) + + +def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + def fake_sync(root: Path, *, write: bool) -> list[str]: + seen["root"] = root + seen["write"] = write + return [] + + monkeypatch.setattr(sync_mod, "sync", fake_sync) + assert sync_mod.main([]) == 0 + assert seen == {"root": sync_mod.REPO_ROOT, "write": True} + + +def test_repository_is_in_sync() -> None: + """The real checkout must match; a failure here means a rev has drifted. + + Also proves every SYNC_TARGETS entry still resolves in the real files. + """ + assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == [] + + +def test_cli_entry_point(root: Path) -> None: + """Run the script the way the workflow does, as a subprocess.""" + script = Path(sync_mod.__file__) + result = subprocess.run( + [sys.executable, str(script), "--check", "--root", str(root)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + assert result.stdout.splitlines() == EXPECTED_DRIFT From 390742cf9ba113ea89bc88a05582c4c409a1bd63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:47:46 +0000 Subject: [PATCH 022/759] Bump ruff from 0.16.5 to 0.16.6 (#19022) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- esphome/api_client.py | 4 +--- esphome/components/debug/sensor.py | 6 +----- esphome/components/debug/text_sensor.py | 6 +----- esphome/components/esp32/const.py | 11 ++--------- esphome/components/nextion/display.py | 7 +------ esphome/happy_eyeballs.py | 5 +---- requirements_test.txt | 2 +- 8 files changed, 9 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1af0e19273a..95e6f0f73e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.5 + rev: v0.16.6 hooks: # Run the linter. - id: ruff diff --git a/esphome/api_client.py b/esphome/api_client.py index fb41075de82..2b93b4790f7 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -23,9 +23,7 @@ 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 - ) + from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index e53cb0d1e4a..80d1daa81f1 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -23,11 +23,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 9d4fcc1b42a..2e02af67cbe 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -9,11 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index e7d8a66e7ae..a0c9809c50c 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -3,18 +3,11 @@ import esphome.codegen as cg # Re-exported for the many esp32-side users; defined in esphome.const # and esphome.espidf so the upload/logs fast path can use them without # importing this package. -from esphome.const import ( # noqa: F401 # pylint: disable=unused-import - KEY_ESP32, - KEY_FLASH_SIZE, - KEY_IDF_VERSION, - KEY_VARIANT, -) +from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import # Back compat for external components only; in-tree callers import it # from esphome.espidf directly. -from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import - variant_to_idf_target, -) +from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import KEY_BOARD = "board" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3f5ba94b403..a5894bdaf7a 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -14,12 +14,7 @@ from esphome.const import ( ) from esphome.core import CORE, TimePeriod -from . import ( # noqa: F401 pylint: disable=unused-import - FILTER_SOURCE_FILES, - Nextion, - nextion_ns, - nextion_ref, -) +from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( CONF_AUTO_WAKE_ON_TOUCH, CONF_COMMAND_SPACING, diff --git a/esphome/happy_eyeballs.py b/esphome/happy_eyeballs.py index 35092e7daab..8b0d0208621 100644 --- a/esphome/happy_eyeballs.py +++ b/esphome/happy_eyeballs.py @@ -69,10 +69,7 @@ def _make_create_connection() -> Callable[..., socket.socket]: from aiohappyeyeballs import start_connection from urllib3.exceptions import LocationParseError - from urllib3.util.connection import ( # noqa: PLC2701 - _set_socket_options, - allowed_gai_family, - ) + from urllib3.util.connection import _set_socket_options, allowed_gai_family # noqa: PLC2701 from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701 from esphome import async_thread diff --git a/requirements_test.txt b/requirements_test.txt index ef70a5ac0c0..9fd82b75096 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.5 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.1 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 89a56298c231a138080a8604deea8ebb5a630369 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:00:28 +0000 Subject: [PATCH 023/759] Bump prek from 0.5.1 to 0.5.2 (#19021) 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 9fd82b75096..cd0427f33e1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.1 # .github/workflows/ci.yml reads this pin +prek==0.5.2 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests From 50ca38119873fc717db2ec00ef9b614d5539921b Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:10:55 +0200 Subject: [PATCH 024/759] [i2s_audio] Keep a start request that arrives while the speaker task stops (#19027) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 5e271e671e5..1c2eb129046 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -91,7 +91,14 @@ void I2SAudioSpeakerBase::loop() { this->speaker_task_handle_ = nullptr; this->stop_i2s_driver_(); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } this->status_clear_error(); this->on_task_stopped(); From 56c3361b9adafd3f1f433987d04f27f549a4d965 Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:13:03 +0200 Subject: [PATCH 025/759] [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028) Co-authored-by: Claude --- esphome/components/audio/audio_decoder.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index fe9ad9c9add..051395606c8 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() { this->output_transfer_buffer_->increase_buffer_length( this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); } - } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { - // First successful header parse: capture stream info and resize the output buffer to fit one full frame. - // microMP3 always outputs 16-bit PCM. + } else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) { + // Header parsed: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its + // negative value it is documented as recoverable, so it must not reach the catch-all below. this->audio_stream_info_ = audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); this->free_buffer_required_ = From 62eafc477d9ab731b1ce5b8d493d5b69c7e5b468 Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:09:02 -0400 Subject: [PATCH 026/759] [mqtt] Restore brightness flag in light discovery (#18950) --- esphome/components/mqtt/mqtt_light.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index aa47bdf996a..a8b52a3839f 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -67,6 +67,9 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE)) color_modes.add(ESPHOME_F("rgbww")); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) + root[ESPHOME_F("brightness")] = true; + if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) || traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) { root[MQTT_MIN_MIREDS] = traits.get_min_mireds(); From 639ce609bf70332146f25d04cdf5ac6a15bb4ee2 Mon Sep 17 00:00:00 2001 From: AndreKR Date: Tue, 8 Sep 2026 03:13:51 +0200 Subject: [PATCH 027/759] [logger] Fix garbled stack traces (#17939) --- esphome/components/logger/logger_esp32.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 05fc959ceb2..c3d777299d9 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -5,6 +5,7 @@ #include #include +#include #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include @@ -76,7 +77,11 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_config.parity = UART_PARITY_DISABLE; uart_config.stop_bits = UART_STOP_BITS_1; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; +#if SOC_UART_SUPPORT_XTAL_CLK + uart_config.source_clk = UART_SCLK_XTAL; +#else uart_config.source_clk = UART_SCLK_DEFAULT; +#endif uart_param_config(uart_num, &uart_config); // The logger only writes to UART, never reads, so use the minimum RX buffer. // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). From e6aa575f2e960f406cc8edaccb9719dc11f2a92b Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 7 Sep 2026 18:27:49 -0700 Subject: [PATCH 028/759] [dallas_temp] filter 85 temp from sensor reset (#17877) Co-authored-by: Samuel Sieb --- esphome/components/dallas_temp/dallas_temp.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index ab4a8c458fe..c418362ced1 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -6,6 +6,7 @@ namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; +static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE; static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E; @@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() { default: break; } - + // undocumented test for powerup measurement of 85 + // https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem + if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) { + if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) { + ESP_LOGD(TAG, "dropping reading caused by sensor reset"); + return NAN; + } + } return temp / 16.0f; } From d34ffaf3928ef4a5fdaccc94f30b3ab59f6b75c4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 7 Sep 2026 18:57:50 -0700 Subject: [PATCH 029/759] [ble_client] Report Established from nodes that never read services (#17920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ble_client/automation.h | 34 +++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 94eeb83b3eb..93aae23b6a1 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -22,6 +22,23 @@ class Automation { static const char *const TAG; }; +// Base for nodes that never read the parent's services. +// The parent releases its services only once every node reports Established, so a node that never +// reports it keeps that memory allocated for the life of the connection. +class BLEClientServicelessNode : public BLEClientNode { + public: + // Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event. + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final { + if (event == ESP_GATTC_SEARCH_CMPL_EVT) + this->node_state = espbt::ClientState::ESTABLISHED; + this->on_gattc_event(event, gattc_if, param); + } + + protected: + // Derived nodes handle GATT events here rather than by overriding the handler above. + virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {} +}; + // implement on_connect automation. class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: @@ -61,7 +78,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode } }; -class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +88,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN } }; -class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +99,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger, publ } }; -class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -315,19 +332,17 @@ template class BLEClientRemoveBondAction final : public Action class BLEClientConnectAction final : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { case ESP_GATTC_SEARCH_CMPL_EVT: - this->node_state = espbt::ClientState::ESTABLISHED; this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); }); break; // if the connection is closed, terminate the automation chain. @@ -364,14 +379,13 @@ template class BLEClientConnectAction final : public Action var_{}; }; -template class BLEClientDisconnectAction final : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { From 574762f07861b7008225bb7de89164ed697836da Mon Sep 17 00:00:00 2001 From: Davide D M Date: Tue, 8 Sep 2026 03:59:05 +0200 Subject: [PATCH 030/759] [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) --- esphome/components/debug/debug_esp32.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 969cd840cf6..8e1a67224eb 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -66,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; - if (pref.load(&reboot_source)) { + if (pref.load(&reboot_source) && reboot_source[0] != '\0') { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; snprintf(buf, size, "Reboot request from %s", reboot_source); } else { From 94e5c3839d8372e95a320cd1796ca500de75be91 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:17:22 +1200 Subject: [PATCH 031/759] [udp] Use cv.invalid for relocated packet_transport options (#19032) --- esphome/components/udp/__init__.py | 16 +++------ tests/unit_tests/components/udp/__init__.py | 0 tests/unit_tests/components/udp/test_init.py | 37 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/components/udp/__init__.py create mode 100644 tests/unit_tests/components/udp/test_init.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index a782d875b9d..d96a731e9c0 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,5 +1,4 @@ -from collections.abc import Callable -from typing import Any, NoReturn +from typing import Any from esphome import automation from esphome.automation import Trigger @@ -48,17 +47,10 @@ UDP_SCHEMA = cv.Schema( ) -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" - ) - - return validator - - RELOCATED = { - cv.Optional(x): is_relocated(x) + cv.Optional(x): cv.invalid( + f"The '{x}' option should now be configured in the 'packet_transport' component" + ) for x in ( CONF_PROVIDERS, CONF_ENCRYPTION, diff --git a/tests/unit_tests/components/udp/__init__.py b/tests/unit_tests/components/udp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/udp/test_init.py b/tests/unit_tests/components/udp/test_init.py new file mode 100644 index 00000000000..5afc92e9c67 --- /dev/null +++ b/tests/unit_tests/components/udp/test_init.py @@ -0,0 +1,37 @@ +"""Tests for the udp component configuration schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components import udp +from esphome.components.packet_transport import ( + CONF_BINARY_SENSORS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_PROVIDERS, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, +) +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "option", + [ + CONF_PROVIDERS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, + CONF_BINARY_SENSORS, + ], +) +def test_relocated_option_rejected(option: str) -> None: + """Options that moved to packet_transport raise a pointing error.""" + with pytest.raises(cv.Invalid) as exc_info: + udp.CONFIG_SCHEMA({option: True}) + assert ( + f"The '{option}' option should now be configured in the 'packet_transport' component" + in str(exc_info.value) + ) From 5722ccba372857c175e75de217b2e455ca57f88d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:36:04 -0400 Subject: [PATCH 032/759] [tuya] Build without a network component (#18948) --- esphome/components/tuya/tuya.cpp | 17 +++++++++-- .../tuya/test-no-network.bk72xx-ard.yaml | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/test-no-network.bk72xx-ard.yaml diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 82fb96d7879..f9b4fe24532 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -1,10 +1,13 @@ #include "tuya.h" -#include "esphome/components/network/util.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -22,6 +25,14 @@ static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static bool network_is_connected() { +#ifdef USE_NETWORK + return network::is_connected(); +#else + return false; +#endif +} + void Tuya::setup() { this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { @@ -554,14 +565,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) { } void Tuya::set_status_pin_() { - bool is_network_ready = network::is_connected() && remote_is_connected(); + bool is_network_ready = network_is_connected() && remote_is_connected(); this->status_pin_->digital_write(is_network_ready); } uint8_t Tuya::get_wifi_status_code_() { uint8_t status = 0x02; - if (network::is_connected()) { + if (network_is_connected()) { status = 0x03; // Protocol version 3 also supports specifying when connected to "the cloud" diff --git a/tests/components/tuya/test-no-network.bk72xx-ard.yaml b/tests/components/tuya/test-no-network.bk72xx-ard.yaml new file mode 100644 index 00000000000..64207e94e38 --- /dev/null +++ b/tests/components/tuya/test-no-network.bk72xx-ard.yaml @@ -0,0 +1,29 @@ +# Tuya without any network component (no wifi/ethernet/api), as used on +# serial-only or BLE-only Tuya MCU boards. Regression test for +# https://github.com/esphome/esphome/issues/18942 +substitutions: + status_pin: P6 + +packages: + uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml + +tuya: + status_pin: ${status_pin} + +binary_sensor: + - platform: tuya + id: tuya_presence + sensor_datapoint: 101 + +sensor: + - platform: tuya + id: tuya_light_intensity + sensor_datapoint: 103 + +number: + - platform: tuya + id: tuya_far_detection + number_datapoint: 109 + min_value: 0 + max_value: 600 + step: 1 From 53075e41391a706a52d69885f70057cc9616c675 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 06:42:30 +0200 Subject: [PATCH 033/759] [core] Skip PlatformIO's private-package authorization probe (#18823) --- esphome/platformio/library.py | 6 ++- esphome/platformio/prefetch.py | 2 + esphome/platformio/runner.py | 14 ++++++- tests/unit_tests/test_platformio_library.py | 19 ++++++++++ tests/unit_tests/test_platformio_prefetch.py | 14 +++++++ tests/unit_tests/test_platformio_runner.py | 40 ++++++++++++++++++++ 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 3ff60f8aaab..fb6779b8078 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -616,11 +616,15 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.registry.client import RegistryClient class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: - self._registry_client = None self.pkg_type = "library" + self._registry_client = RegistryClient() + # The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages); + # instance-level so the ESPHome process never patches PlatformIO's class + self._registry_client.allowed_private_packages = lambda: False @staticmethod def is_system_compatible(value: Any, custom_system: Any = None) -> bool: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 17a06cb9c10..e648192b737 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -951,8 +951,10 @@ def main(argv: list[str]) -> int: """Subprocess entry point: ``prefetch ``.""" from esphome.core import CORE from esphome.log import setup_log + from esphome.platformio.runner import patch_registry_private_packages signal.signal(signal.SIGTERM, _sigterm) + patch_registry_private_packages() raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL") try: level = int(raw_level) if raw_level is not None else logging.INFO diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 9bb2205a909..b9fbdec38d0 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -2,7 +2,8 @@ Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild -preservation, download retries) apply inside the subprocess. Running +preservation, download retries, skipping the private-package probe) apply +inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other global state from leaking into the ESPHome process. """ @@ -105,6 +106,16 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def patch_registry_private_packages() -> None: + """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. + + ESPHome never uses private packages, so the answer is always False. + """ + from platformio.registry.client import RegistryClient + + RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign] + + _IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. @@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [ def main() -> int: patch_structhash() patch_file_downloader() + patch_registry_private_packages() # Wrap stdout/stderr with RedirectText before PlatformIO runs: # diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 3bae39b3c1a..512c883c374 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -7,6 +7,7 @@ exercised in their own test modules).""" import json import logging from pathlib import Path +from unittest.mock import Mock import pytest @@ -228,6 +229,24 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) +def test_make_registry_client_skips_private_package_probe(monkeypatch): + """Our client answers the probe locally without patching PlatformIO's class.""" + from platformio.account.client import AccountClient + from platformio.registry.client import RegistryClient + + pio_probe = RegistryClient.__dict__["allowed_private_packages"] + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + client = lib._make_registry_client().get_registry_client_instance() + + assert client.allowed_private_packages() is False + assert RegistryClient.__dict__["allowed_private_packages"] is pio_probe + + def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 77490fd8613..14c52dda8d5 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1225,6 +1225,20 @@ def test_main_runs_prefetch(tmp_path: Path) -> None: mock_prefetch.assert_called_once_with(tmp_path, "testenv") +def test_main_skips_private_package_probe_before_prefetch(tmp_path: Path) -> None: + """The registry probe patch is applied before any package manager runs.""" + order: list[str] = [] + with ( + patch.object(pf, "_prefetch", side_effect=lambda *_: order.append("prefetch")), + patch( + "esphome.platformio.runner.patch_registry_private_packages", + side_effect=lambda: order.append("patch"), + ), + ): + assert pf.main([str(tmp_path), "testenv"]) == 0 + assert order == ["patch", "prefetch"] + + def test_main_bad_argv_is_a_distinct_exit( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index f375aa457ac..007455f45a3 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -6,7 +6,9 @@ from collections.abc import Callable import io import sys from types import ModuleType +from unittest.mock import Mock +from platformio.registry.client import RegistryClient import pytest from esphome.platformio import runner @@ -30,6 +32,7 @@ def _prepare_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(runner, "patch_structhash", lambda: None) monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + monkeypatch.setattr(runner, "patch_registry_private_packages", lambda: None) platformio = ModuleType("platformio") platformio_main = ModuleType("platformio.__main__") @@ -91,3 +94,40 @@ def test_main_still_filters_a_drained_partial_line( assert runner.main() == 0 assert buf.getvalue() == b"" + + +def test_main_applies_registry_private_packages_patch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The probe is patched before PlatformIO runs.""" + order: list[str] = [] + _prepare_main(monkeypatch, lambda: order.append("pio") or 0) + monkeypatch.setattr( + runner, "patch_registry_private_packages", lambda: order.append("patch") + ) + + assert runner.main() == 0 + assert order == ["patch", "pio"] + + +# Snapshot PlatformIO's own probe at import, before any test can patch it +_PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] + + +def test_patch_registry_private_packages_skips_account_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Answers False without touching the account client.""" + from platformio.account.client import AccountClient + + monkeypatch.setattr(RegistryClient, "allowed_private_packages", _PIO_PROBE) + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + runner.patch_registry_private_packages() + + assert RegistryClient.allowed_private_packages() is False + assert RegistryClient().allowed_private_packages() is False From a23f7bb5693a3ef0ec23e0bcc49f6e30561ae986 Mon Sep 17 00:00:00 2001 From: Gytis Date: Tue, 8 Sep 2026 08:31:30 +0200 Subject: [PATCH 034/759] [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) --- esphome/components/lvgl/widgets/keyboard.py | 3 +- esphome/components/lvgl/widgets/qrcode.py | 3 +- esphome/components/lvgl/widgets/tabview.py | 3 +- .../lvgl/config/keyboard_no_label.yaml | 32 +++++++++++++++++ .../lvgl/config/qrcode_no_label.yaml | 34 ++++++++++++++++++ .../lvgl/config/tabview_no_label.yaml | 35 +++++++++++++++++++ .../lvgl/test_widget_label_dependency.py | 32 +++++++++++++++++ 7 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/config/keyboard_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/qrcode_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/tabview_no_label.yaml create mode 100644 tests/component_tests/lvgl/test_widget_label_dependency.py diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index bcd2d2ae597..65516513a6c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -15,6 +15,7 @@ from ..defines import ( from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX +from .label import CONF_LABEL from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -49,7 +50,7 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL async def to_code(self, w: Widget, config: dict): add_lv_use("KEY_LISTENER") diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index df76ab6bb0d..59af9168aad 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -10,6 +10,7 @@ from ..types import lv_obj_t from . import Widget, WidgetType from .canvas import CONF_CANVAS from .img import CONF_IMAGE +from .label import CONF_LABEL CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -41,7 +42,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return CONF_CANVAS, CONF_IMAGE + return CONF_CANVAS, CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): await w.set_property( diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index ee252ecf0b2..77c88c48ff8 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec +from .label import CONF_LABEL from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -74,7 +75,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/tests/component_tests/lvgl/config/keyboard_no_label.yaml b/tests/component_tests/lvgl/config/keyboard_no_label.yaml new file mode 100644 index 00000000000..7a45a537d3d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_no_label.yaml @@ -0,0 +1,32 @@ +esphome: + name: test-keyboard-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - keyboard: + id: keyboard_widget diff --git a/tests/component_tests/lvgl/config/qrcode_no_label.yaml b/tests/component_tests/lvgl/config/qrcode_no_label.yaml new file mode 100644 index 00000000000..8bb1aafdd6e --- /dev/null +++ b/tests/component_tests/lvgl/config/qrcode_no_label.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-qrcode-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - qrcode: + id: qr_widget + size: 100 + text: "esphome.io" diff --git a/tests/component_tests/lvgl/config/tabview_no_label.yaml b/tests/component_tests/lvgl/config/tabview_no_label.yaml new file mode 100644 index 00000000000..a3c16ab3471 --- /dev/null +++ b/tests/component_tests/lvgl/config/tabview_no_label.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-tabview-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - tabview: + id: tabview_widget + tabs: + - name: "Tab 1" + id: tab_1 diff --git a/tests/component_tests/lvgl/test_widget_label_dependency.py b/tests/component_tests/lvgl/test_widget_label_dependency.py new file mode 100644 index 00000000000..9d3e24c8c5a --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_label_dependency.py @@ -0,0 +1,32 @@ +"""Widgets whose LVGL C implementation creates or references labels +internally (tab titles, key legends, the QR canvas fallback) must declare +the label dependency in ``get_uses()``. Otherwise a config that contains +no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and +fails at C compile time with undefined ``lv_label_*`` symbols. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df + + +@pytest.mark.parametrize( + "yaml_file", + [ + "qrcode_no_label.yaml", + "keyboard_no_label.yaml", + "tabview_no_label.yaml", + ], +) +def test_label_less_config_enables_lv_use_label( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, +) -> None: + generate_main(component_config_path(yaml_file)) + assert "LV_USE_LABEL" in df.get_defines() From 10a9baff746613b52df73ff07895e177cf1a0f81 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Mon, 7 Sep 2026 23:36:42 -0700 Subject: [PATCH 035/759] [rf_bridge] Fix bucket sniffing with Portisch firmware (#17683) Co-authored-by: Bryan Li Co-authored-by: Claude Fable 5 --- esphome/components/rf_bridge/rf_bridge.cpp | 109 +++++++++++++++++---- esphome/components/rf_bridge/rf_bridge.h | 13 +++ 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 549cce72dfd..a4a4da5d8c0 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -18,6 +18,16 @@ void RFBridgeComponent::ack_() { } bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { + if (this->bucket_frame_candidate_ && byte == RF_CODE_START) { + // A queued next frame proves the trailing 0x55 really was the bucket + // frame's terminator: Portisch builds pulse entries from alternating + // signal edges, so the two level bits inside one pulse byte are always + // opposite — 0xAA (two high-level nibbles) cannot occur in pulse data. + // Finalize before this byte starts the new frame, so back-to-back + // deliveries are split even when loop() never observed a quiet gap + // between them. + this->finish_bucket_frame_(); + } size_t at = this->rx_buffer_.size(); this->rx_buffer_.push_back(byte); const uint8_t *raw = &this->rx_buffer_[0]; @@ -84,26 +94,21 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { break; } case RF_CODE_RFIN_BUCKET: { - if (byte != RF_CODE_STOP) { - return true; + if (at == 2) { + // The count byte: Portisch sends at most 7 buckets + sync, so 0 or + // >8 cannot be a genuine capture — reject before it can occupy the + // buffer for a full frame timeout. + return byte != 0 && byte <= B1_MAX_BUCKET_COUNT; } - - uint8_t buckets = raw[2] << 1; - std::string str; - char next_byte[3]; // 2 hex chars + null - - for (uint32_t i = 0; i <= at; i++) { - buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); - str += next_byte; - if ((i > 3) && buckets) { - buckets--; - } - if ((i < 3) || (buckets % 2) || (i == at - 1)) { - str += " "; - } - } - ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); - break; + // 0x55 is legal DATA inside a B1 frame: bucket durations are sent + // with only their HIGH byte masked to 7 bits, so a duration such as + // 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55 + // must therefore not end the capture. The header declares the table + // length (raw[2] pairs), so a 0x55 there is always data; one at or + // past the first pulse index is a terminator CANDIDATE, confirmed + // once the UART goes quiet (finish_bucket_frame_ in loop()). + this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast(raw[2]) * 2; + return true; } default: ESP_LOGW(TAG, "Unknown action: 0x%02X", action); @@ -119,6 +124,47 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return false; } +void RFBridgeComponent::finish_bucket_frame_() { + if (this->rx_buffer_.size() < 4) { + // The candidate flag requires a header + non-empty bucket table, so + // this cannot happen while flag and buffer stay consistent; guard the + // raw[2] / size-1 reads against any future divergence anyway. + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + return; + } + const uint8_t *raw = this->rx_buffer_.data(); + const size_t at = this->rx_buffer_.size() - 1; + + uint8_t buckets = raw[2] << 1; + std::string str; + char next_byte[3]; // 2 hex chars + null + + for (uint32_t i = 0; i <= at; i++) { + buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); + str += next_byte; + if ((i > 3) && buckets) { + buckets--; + } + if ((i < 3) || (buckets % 2) || (i == at - 1)) { + str += " "; + } + } + ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); + + // Deliberately NOT ACKed: Portisch's B1 command handler leaves its + // last_sniffing_command at the previous mode (RF_CODE_RFIN), and its + // host-ACK handler re-arms sniffing from that stale value — so ACKing a + // bucket delivery silently reverts the radio to standard sniffing and + // ends bucket capture. Its delivery path is fire-and-forget and never + // waits for a host ACK. Stock Itead firmware never sends B1 frames, so + // suppressing this ACK cannot change stock-firmware behavior. + // https://github.com/esphome/esphome/issues/17682 + + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; +} + void RFBridgeComponent::write_byte_str_(const std::string &codes) { uint8_t code; int size = codes.length(); @@ -130,12 +176,31 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) { void RFBridgeComponent::loop() { const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_bridge_byte_ > 50) { + size_t avail = this->available(); + if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) { + // The trailing 0x55 was followed by UART quiet, so it really was the + // frame terminator and not an interior data byte. + this->finish_bucket_frame_(); + this->last_bridge_byte_ = now; + } + const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET; + if (receiving_bucket) { + // Never declare an in-progress bucket frame dead while its continuation + // bytes are already queued: a stalled loop() otherwise discards a live + // frame that the UART buffer proves is still arriving. + if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) { + ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)", + static_cast(this->rx_buffer_.size())); + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + this->last_bridge_byte_ = now; + } + } else if (now - this->last_bridge_byte_ > 50) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; this->last_bridge_byte_ = now; } - size_t avail = this->available(); while (avail > 0) { uint8_t buf[64]; size_t to_read = std::min(avail, sizeof(buf)); @@ -146,12 +211,14 @@ void RFBridgeComponent::loop() { for (size_t i = 0; i < to_read; i++) { if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } if (this->parse_bridge_byte_(buf[i])) { ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); this->last_bridge_byte_ = now; } else { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } } } diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 5ad75650abb..cbb1880ec53 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -30,6 +30,17 @@ static const uint8_t RF_CODE_BEEP = 0xC0; static const uint8_t RF_CODE_STOP = 0x55; static const uint8_t RF_DEBOUNCE = 200; static const size_t MAX_RX_BUFFER_SIZE = 512; +// ~10 byte times at 19200 baud: long enough to prove the UART went quiet +// after a possible bucket-frame terminator, short enough to finish well +// before the next radio capture can be delivered. +static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5; +// Portisch drains a B1 frame's header, bucket table, and pulse data as +// separate UART writes, so an in-progress bucket frame tolerates a longer +// inter-region gap than the generic 50 ms inter-byte timeout. +static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250; +// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync +// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol. +static const uint8_t B1_MAX_BUCKET_COUNT = 8; struct RFBridgeData { uint16_t sync; @@ -67,10 +78,12 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { void ack_(); void decode_(); bool parse_bridge_byte_(uint8_t byte); + void finish_bucket_frame_(); void write_byte_str_(const std::string &codes); std::vector rx_buffer_; uint32_t last_bridge_byte_{0}; + bool bucket_frame_candidate_{false}; CallbackManager data_callback_; CallbackManager advanced_data_callback_; From 28588310e74f93140e020fcaf6f95584412f671a Mon Sep 17 00:00:00 2001 From: raykholo Date: Tue, 8 Sep 2026 02:57:33 -0400 Subject: [PATCH 036/759] [anova] Re-assert temperature unit on every poll cycle (#17141) --- esphome/components/anova/anova.cpp | 107 ++++++++++++++--------------- esphome/components/anova/anova.h | 13 +++- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 6e382872e2b..b0769bb622e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } void Anova::setup() { this->codec_ = make_unique(); - this->current_request_ = 0; + this->poll_step_ = PollStep::IDLE; } void Anova::loop() { @@ -22,6 +22,15 @@ void Anova::loop() { this->disable_loop(); } +void Anova::write_request_(AnovaPacket *pkt) { + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, + pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } +} + void Anova::control(const ClimateCall &call) { auto mode_val = call.get_mode(); if (mode_val.has_value()) { @@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) { ESP_LOGW(TAG, "Unsupported mode: %d", mode); return; } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(pkt); } auto target_temp = call.get_target_temperature(); if (target_temp.has_value()) { - auto *pkt = this->codec_->get_set_target_temp_request(*target_temp); - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(this->codec_->get_set_target_temp_request(*target_temp)); } } @@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_DISCONNECT_EVT: { this->current_temperature = NAN; this->target_temperature = NAN; + this->poll_step_ = PollStep::IDLE; this->publish_state(); break; } @@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; - this->current_request_ = 0; - this->update(); + this->poll_step_ = PollStep::IDLE; + this->update(); // begin the first poll cycle immediately break; } case ESP_GATTC_NOTIFY_EVT: { @@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; } if (this->codec_->has_unit()) { - this->fahrenheit_ = (this->codec_->unit_ == 'f'); - ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius"); - this->current_request_++; + ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius"); } this->publish_state(); - if (this->current_request_ > 1) { - AnovaPacket *pkt = nullptr; - switch (this->current_request_++) { - case 2: - pkt = this->codec_->get_read_target_temp_request(); - break; - case 3: - pkt = this->codec_->get_read_current_temp_request(); - break; - default: - this->current_request_ = 1; - break; - } - if (pkt != nullptr) { - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - } + // Advance the poll cycle to its next request based on the reply we got. + switch (this->poll_step_) { + case PollStep::SET_UNIT: + this->poll_step_ = PollStep::STATUS; + this->write_request_(this->codec_->get_read_device_status_request()); + break; + case PollStep::STATUS: + this->poll_step_ = PollStep::TARGET; + this->write_request_(this->codec_->get_read_target_temp_request()); + break; + case PollStep::TARGET: + this->poll_step_ = PollStep::CURRENT; + this->write_request_(this->codec_->get_read_current_temp_request()); + break; + case PollStep::CURRENT: + this->poll_step_ = PollStep::IDLE; // full cycle complete + break; + default: + // A reply to an ad-hoc control() write, outside a managed cycle. + break; } break; } @@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } } -void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); } +void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); } void Anova::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) return; - - if (this->current_request_ < 2) { - AnovaPacket *pkt; - if (this->current_request_ == 0) { - pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); - } else { - pkt = this->codec_->get_read_device_status_request(); - } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - this->current_request_++; + if (this->poll_step_ != PollStep::IDLE) { + // The previous cycle never finished within a full polling interval -- a + // reply was missed or a write failed. Restart the cycle rather than stall; + // the polling interval itself acts as the timeout. A late reply from the + // abandoned cycle is harmless: state decoding happens on every notify + // regardless of step, and each notify sends at most one follow-up request. + ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(), + static_cast(this->poll_step_)); } + // Re-assert the configured unit at the start of every poll cycle, then fall + // through the status/temperature reads via the notification handler. Always + // command the configured unit (want_fahrenheit_) -- never the last value the + // device reported, or a drift to 'c' would lock itself in. + this->poll_step_ = PollStep::SET_UNIT; + this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c')); } } // namespace esphome::anova diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 49b1100c372..a0fa03df016 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie void set_unit_of_measurement(const char *unit); protected: + // A poll cycle re-asserts the configured unit, then reads device state. + // Re-asserting every cycle prevents the cooker from silently reverting to + // its default (Celsius); previously the unit was only set once on + // connection, so a drift persisted (and corrupted the F/C interpretation of + // subsequent readings) until the BLE link was re-established. + enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE }; + + void write_request_(AnovaPacket *pkt); + std::unique_ptr codec_; void control(const climate::ClimateCall &call) override; uint16_t char_handle_; - uint8_t current_request_; - bool fahrenheit_; + bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies + PollStep poll_step_{PollStep::IDLE}; }; } // namespace esphome::anova From 1700a40b7cc0cb4a033ba7f2d7c27c1ba65ea0f7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:55 +1200 Subject: [PATCH 037/759] [core] Restore the shared git hooks after post-checkout runs script/setup (#19036) --- script/git-hooks/post-checkout | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/script/git-hooks/post-checkout b/script/git-hooks/post-checkout index 8f4085ae6e1..853c2b03521 100755 --- a/script/git-hooks/post-checkout +++ b/script/git-hooks/post-checkout @@ -14,7 +14,31 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 [ -x "$top/venv/bin/python" ] && exit 0 [ -x "$top/script/setup" ] || exit 0 +# Every worktree shares the hooks directory of the checkout it was created +# from, and the script/setup run below is the one from whichever branch was just +# checked out. Older branches install their own pre-commit hook without checking +# for a worktree: that moves the shared hook aside as pre-commit.legacy and +# replaces it with one tied to this worktree's virtual environment, so commits +# break in every checkout. To rule that out, the hooks directory is copied +# before script/setup runs and put back exactly as it was afterwards, including +# removing any file script/setup added. +hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0 +snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0 +cp -p "$hooks"/* "$snap"/ 2>/dev/null + # Clear VIRTUAL_ENV so a checkout made from a shell with an environment already # activated still gets its own, rather than having the active one repointed at # this working tree. -exec env -u VIRTUAL_ENV "$top/script/setup" +env -u VIRTUAL_ENV "$top/script/setup" +status=$? + +for f in "$hooks"/*; do + [ -e "$snap/${f##*/}" ] || rm -f "$f" +done +# Files are moved rather than copied so a hook that is still running, such as +# this one, is swapped out atomically instead of being rewritten in place. +for f in "$snap"/*; do + cmp -s "$f" "$hooks/${f##*/}" 2>/dev/null || mv -f "$f" "$hooks/${f##*/}" +done +rm -rf "$snap" +exit $status From 227ca90aad10d3084e3e8a45a13c46aea65a6c3f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:00:08 +1200 Subject: [PATCH 038/759] [core] Restore the shared git hooks after post-checkout runs script/setup (#19036) From f191d5e0c384ca785e562e652cc03cdaf21a99d4 Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:02:49 -0400 Subject: [PATCH 039/759] [atm90e32] Verify offset calibration writes (#18701) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.cpp | 360 ++++++++++-------- esphome/components/atm90e32/atm90e32.h | 71 ++-- tests/components/atm90e32/__init__.py | 5 + .../offset_register_verification_test.cpp | 62 +++ 4 files changed, 322 insertions(+), 176 deletions(-) create mode 100644 tests/components/atm90e32/__init__.py create mode 100644 tests/components/atm90e32/offset_register_verification_test.cpp diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d948b3741df..23701e78348 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -9,6 +9,10 @@ namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; +static const LogString *offset_calibration_name(bool power_offsets) { + return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); +} + static uint32_t pref_hash(const char *prefix, const char *name_space) { auto hash = fnv1_hash(prefix); return fnv1_hash_extend(hash, name_space); @@ -203,13 +207,12 @@ void ATM90E32Component::setup() { // Initialize flash storage for power offset calibrations uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); - this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); bool migrated_power_offset = false; if (has_distinct_legacy_namespace) { uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); - auto legacy_power_offset_pref = - global_preferences->make_preference(legacy_po_hash, true); - PowerOffsetCalibration power_offset_data[3]{}; + auto legacy_power_offset_pref = global_preferences->make_preference(legacy_po_hash, true); + OffsetCalibration power_offset_data[3]{}; int migration_status = migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); migrated_power_offset = migration_status > 0; @@ -224,20 +227,20 @@ void ATM90E32Component::setup() { global_preferences->sync(); } - this->restore_offset_calibrations_(); - this->restore_power_offset_calibrations_(); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], - static_cast(this->offset_phase_[phase].voltage_offset_)); + static_cast(this->offset_phase_[phase].first_offset)); this->write16_(this->current_offset_registers[phase], - static_cast(this->offset_phase_[phase].current_offset_)); + static_cast(this->offset_phase_[phase].second_offset)); this->write16_(this->power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].active_power_offset)); + static_cast(this->power_offset_phase_[phase].first_offset)); this->write16_(this->reactive_power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].reactive_power_offset)); + static_cast(this->power_offset_phase_[phase].second_offset)); } } @@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, - this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset, + this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].active_power_offset, - this->config_power_offset_phase_[phase].reactive_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset, + this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); } @@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); } @@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } } -void ATM90E32Component::save_offset_calibration_to_memory_() { +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); - bool success = this->offset_pref_.save(&this->offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_offset_calibration_ = true; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); - } -} + const LogString *name = offset_calibration_name(power_offsets); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; -void ATM90E32Component::save_power_offset_calibration_to_memory_() { - const char *cs = this->get_calibration_id_(); - bool success = this->power_offset_pref_.save(&this->power_offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_power_offset_calibration_ = true; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); + const bool writes_verified = this->verify_offset_writes_(type); + bool saved = false; + bool synced = false; + if (writes_verified) { + saved = preference->save(offsets); + synced = global_preferences->sync(); } + + if (writes_verified && saved && synced) { + this->using_saved_calibrations_ = true; + *has_stored = true; + *restored = true; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, + LOG_STR_ARG(name), LOG_STR_ARG(name)); + return; + } + + if (writes_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + } + const bool rollback_verified = this->verify_offset_writes_(type); + + bool rollback_persisted = false; + if (writes_verified) { + OffsetCalibration rollback[3]{}; + prepare_offset_rollback(previous, previous_restored, rollback); + const bool rollback_saved = preference->save(&rollback); + const bool rollback_synced = global_preferences->sync(); + rollback_persisted = rollback_saved && rollback_synced; + if (!rollback_saved || !rollback_synced) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + } + } + + *restored = previous_restored; + if (rollback_persisted) + *has_stored = previous_restored; + this->using_saved_calibrations_ = previous_using_saved; + if (!rollback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, + LOG_STR_ARG(name)); + return; + } + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } void ATM90E32Component::run_offset_calibrations() { @@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; + const bool previous_restored = this->restored_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); @@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->save_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], + this->power_offset_phase_[2]}; + const bool previous_restored = this->restored_power_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_power_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } -void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) { - // Save to runtime - this->offset_phase_[phase].voltage_offset_ = voltage_offset; - this->phase_[phase].voltage_offset_ = voltage_offset; +void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase]; + offsets.first_offset = first_offset; + offsets.second_offset = second_offset; + if (power_offsets) { + this->phase_[phase].active_power_offset_ = first_offset; + this->phase_[phase].reactive_power_offset_ = second_offset; + } else { + this->phase_[phase].voltage_offset_ = first_offset; + this->phase_[phase].current_offset_ = second_offset; + } - // Save to flash-storable struct - this->offset_phase_[phase].current_offset_ = current_offset; - this->phase_[phase].current_offset_ = current_offset; - - // Write to registers + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(voltage_offset_registers[phase], static_cast(voltage_offset)); - this->write16_(current_offset_registers[phase], static_cast(current_offset)); - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); -} - -void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) { - // Save to runtime - this->phase_[phase].active_power_offset_ = p_offset; - this->phase_[phase].reactive_power_offset_ = q_offset; - - // Save to flash-storable struct - this->power_offset_phase_[phase].active_power_offset = p_offset; - this->power_offset_phase_[phase].reactive_power_offset = q_offset; - - // Write to registers - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(this->power_offset_registers[phase], static_cast(p_offset)); - this->write16_(this->reactive_power_offset_registers[phase], static_cast(q_offset)); + this->write16_(first_registers[phase], static_cast(first_offset)); + this->write16_(second_registers[phase], static_cast(second_offset)); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } @@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() { ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } -void ATM90E32Component::restore_offset_calibrations_() { +void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); + const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset"); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + OffsetCalibration(*config_offsets)[3] = + power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; + const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_; + const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_; + for (uint8_t i = 0; i < 3; ++i) - this->config_offset_phase_[i] = this->offset_phase_[i]; - - bool have_data = this->offset_pref_.load(&this->offset_phase_); + (*config_offsets)[i] = (*offsets)[i]; + const bool have_data = preference->load(offsets); bool all_zero = true; if (have_data) { - for (auto &phase : this->offset_phase_) { - if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + for (const auto &phase : *offsets) { + if (phase.first_offset != 0 || phase.second_offset != 0) { all_zero = false; break; } } } - if (have_data && !all_zero) { - this->restored_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; phase++) { - auto &offset = this->offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_voltage_offset_[phase] && - offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) - mismatch = true; - if (this->has_config_current_offset_[phase] && - offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) - mismatch = true; - if (mismatch) - this->offset_calibration_mismatch_[phase] = true; + *has_stored = have_data && !all_zero; + *restored = false; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = false; + if (*has_stored) { + mismatches[phase] = + (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); } - } else { + } + + if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) - this->offset_phase_[phase] = this->config_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + (*offsets)[phase] = (*config_offsets)[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name)); } for (uint8_t phase = 0; phase < 3; phase++) { - write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, - this->offset_phase_[phase].current_offset_); + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } -} - -void ATM90E32Component::restore_power_offset_calibrations_() { - const char *cs = this->get_calibration_id_(); - for (uint8_t i = 0; i < 3; ++i) - this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; - - bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); - - bool all_zero = true; - if (have_data) { - for (auto &phase : this->power_offset_phase_) { - if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { - all_zero = false; - break; - } - } + const bool initial_values_verified = this->verify_offset_writes_(type); + if (initial_values_verified) { + const auto state = resolve_offset_restore_state(*has_stored, true, false); + *restored = state.restored; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); + return; } - if (have_data && !all_zero) { - this->restored_power_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; ++phase) { - auto &offset = this->power_offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_active_power_offset_[phase] && - offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) - mismatch = true; - if (this->has_config_reactive_power_offset_[phase] && - offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) - mismatch = true; - if (mismatch) - this->power_offset_calibration_mismatch_[phase] = true; - } + this->using_saved_calibrations_ = false; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + for (uint8_t phase = 0; phase < 3; phase++) { + (*offsets)[phase] = (*config_offsets)[phase]; + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); + } + const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); + *restored = state.restored; + if (state.values_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_ARG(name)); } else { - for (uint8_t phase = 0; phase < 3; ++phase) - this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); - } - - for (uint8_t phase = 0; phase < 3; ++phase) { - write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_ARG(name)); } } @@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() { void ATM90E32Component::clear_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_offset_calibration_) { + if (!this->has_stored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); return; @@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = - this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0; int16_t current_offset = - this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } @@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() { this->offset_pref_.save(&zero_offsets); // Clear stored values in flash global_preferences->sync(); + this->has_stored_offset_calibration_ = false; this->restored_offset_calibration_ = false; for (bool &phase : this->offset_calibration_mismatch_) phase = false; @@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() { void ATM90E32Component::clear_power_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_power_offset_calibration_) { + if (!this->has_stored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); return; @@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t active_offset = - this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; - int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] - ? this->config_power_offset_phase_[phase].reactive_power_offset - : 0; - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0; + int16_t reactive_offset = + this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; this->power_offset_pref_.save(&zero_power_offsets); global_preferences->sync(); + this->has_stored_power_offset_calibration_ = false; this->restored_power_offset_calibration_ = false; for (bool &phase : this->power_offset_calibration_mismatch_) phase = false; @@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() { return success; // Return true if all writes were successful, false otherwise } +bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const char *cs = this->get_calibration_id_(); + const LogString *name = offset_calibration_name(power_offsets); + const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage"); + const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current"); + const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_; + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; + bool success = true; + for (uint8_t phase = 0; phase < 3; phase++) { + const uint16_t first = this->read16_(first_registers[phase]); + const uint16_t second = this->read16_(second_registers[phase]); + if (!offset_register_value_matches(first, offsets[phase].first_offset) || + !offset_register_value_matches(second, offsets[phase].second_offset)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name), + phase_labels[phase], LOG_STR_ARG(first_name), static_cast(first), offsets[phase].first_offset, + LOG_STR_ARG(second_name), static_cast(second), offsets[phase].second_offset); + success = false; + } + } + return success; +} + #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c636e5065a5..fe7d903962f 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,6 +13,40 @@ namespace esphome::atm90e32 { +inline bool offset_register_value_matches(uint16_t actual, int16_t expected) { + return actual == static_cast(expected); +} + +struct OffsetCalibration { + int16_t first_offset{0}; + int16_t second_offset{0}; +}; + +static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); + +enum class OffsetCalibrationType : uint8_t { + OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + OFFSET_CALIBRATION_TYPE_POWER, +}; + +struct OffsetRestoreState { + bool restored; + bool values_verified; +}; + +inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, + bool fallback_values_verified) { + if (initial_values_verified) + return {has_stored_values, true}; + return {false, fallback_values_verified}; +} + +inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, + OffsetCalibration (&rollback)[3]) { + for (uint8_t phase = 0; phase < 3; phase++) + rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; +} + class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent, this->has_config_current_gain_[phase] = true; } void set_voltage_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].voltage_offset_ = offset; + this->offset_phase_[phase].first_offset = offset; this->has_config_voltage_offset_[phase] = true; } void set_current_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].current_offset_ = offset; + this->offset_phase_[phase].second_offset = offset; this->has_config_current_offset_[phase] = true; } void set_active_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].active_power_offset = offset; + this->power_offset_phase_[phase].first_offset = offset; this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].reactive_power_offset = offset; + this->power_offset_phase_[phase].second_offset = offset; this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } @@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent, float get_chip_temperature_(); bool get_publish_interval_flag_() { return publish_interval_flag_; }; void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; - void restore_offset_calibrations_(); - void restore_power_offset_calibrations_(); + void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); - void save_power_offset_calibration_to_memory_(); - void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); - void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type); + void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type); void write_gains_to_registers_(); bool verify_gain_writes_(); + bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); const char *get_calibration_id_(); @@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent, uint32_t cumulative_reverse_active_energy_{0}; } phase_[3]; - struct OffsetCalibration { - int16_t voltage_offset_{0}; - int16_t current_offset_{0}; - } offset_phase_[3]; - + OffsetCalibration offset_phase_[3]; OffsetCalibration config_offset_phase_[3]; - - struct PowerOffsetCalibration { - int16_t active_power_offset{0}; - int16_t reactive_power_offset{0}; - } power_offset_phase_[3]; - - PowerOffsetCalibration config_power_offset_phase_[3]; + OffsetCalibration power_offset_phase_[3]; + OffsetCalibration config_power_offset_phase_[3]; struct GainCalibration { uint16_t voltage_gain{1}; @@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent, bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; const char *instance_id_{nullptr}; + bool has_stored_offset_calibration_{false}; + bool has_stored_power_offset_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/tests/components/atm90e32/__init__.py b/tests/components/atm90e32/__init__.py new file mode 100644 index 00000000000..37d6797e2dd --- /dev/null +++ b/tests/components/atm90e32/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["sensor", "spi"] diff --git a/tests/components/atm90e32/offset_register_verification_test.cpp b/tests/components/atm90e32/offset_register_verification_test.cpp new file mode 100644 index 00000000000..3bb3eb76ea9 --- /dev/null +++ b/tests/components/atm90e32/offset_register_verification_test.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/components/atm90e32/atm90e32.h" + +namespace esphome::atm90e32::testing { + +TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { + EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); + EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); +} + +TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { + EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); + EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) { + const auto state = resolve_offset_restore_state(true, true, false); + + EXPECT_TRUE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) { + const auto state = resolve_offset_restore_state(true, false, true); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) { + const auto state = resolve_offset_restore_state(true, false, false); + + EXPECT_FALSE(state.restored); + EXPECT_FALSE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) { + const auto state = resolve_offset_restore_state(false, true, false); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) { + const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}}; + OffsetCalibration rollback[3]{}; + + prepare_offset_rollback(previous, true, rollback); + for (uint8_t phase = 0; phase < 3; phase++) { + EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset); + EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset); + } + + prepare_offset_rollback(previous, false, rollback); + for (const auto &phase : rollback) { + EXPECT_EQ(phase.first_offset, 0); + EXPECT_EQ(phase.second_offset, 0); + } +} + +} // namespace esphome::atm90e32::testing From f91486305ff20743d086651517d652360ab58ff7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:48 +0200 Subject: [PATCH 040/759] Bump bundled esphome-device-builder to 1.14.5 (#19040) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index da76ab7b6a7..ac84ee4689f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 RUN \ platformio settings set enable_telemetry No \ From 1ce0bed3f672d3a4699dad0cbfd8617c3b8950e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 18:10:45 +0200 Subject: [PATCH 041/759] [core] Share compiled binaries across modbus integration tests (#18945) --- script/helpers.py | 17 + tests/integration/README.md | 7 + tests/integration/conftest.py | 416 ++++++++++++++---- .../fixtures/sensor_filters_batch_window.yaml | 58 --- .../uart_mock_modbus_client_read_write.yaml | 111 ----- .../fixtures/uart_mock_modbus_custom_pdu.yaml | 88 ---- ...t_mock_modbus_deprecated_write_buffer.yaml | 106 ----- .../uart_mock_modbus_lambda_invert.yaml | 95 ---- .../uart_mock_modbus_lambda_write.yaml | 97 ---- .../fixtures/uart_mock_modbus_loopback.yaml | 233 ++++++++++ ...roller.yaml => uart_mock_modbus_mesh.yaml} | 121 ++++- .../uart_mock_modbus_register_offset.yaml | 138 ------ .../fixtures/uart_mock_modbus_server.yaml | 124 ------ ...ock_modbus_server_controller_multiple.yaml | 116 ----- ... => uart_mock_modbus_server_injected.yaml} | 55 ++- tests/integration/host_prefs.py | 14 +- .../test_api_zero_psk_provisioning.py | 1 - .../test_host_preferences_suspend_resume.py | 11 +- tests/integration/test_light_initial_state.py | 8 - tests/integration/test_uart_mock_modbus.py | 23 +- tests/script/test_helpers.py | 28 ++ 21 files changed, 805 insertions(+), 1062 deletions(-) delete mode 100644 tests/integration/fixtures/sensor_filters_batch_window.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_loopback.yaml rename tests/integration/fixtures/{uart_mock_modbus_server_controller.yaml => uart_mock_modbus_mesh.yaml} (58%) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_register_offset.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml rename tests/integration/fixtures/{uart_mock_modbus_server_read_write.yaml => uart_mock_modbus_server_injected.yaml} (52%) diff --git a/script/helpers.py b/script/helpers.py index bf22e158086..a8a237118f9 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]: _TEST_FUNC_RE = re.compile(r"async def (test_\w+)") +# Any usage form (decorator, pytestmark assignment or list element); only +# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit +_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml") +_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)") @cache @@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]: for func in _TEST_FUNC_RE.findall(content): base_name = func.replace("test_", "").partition("[")[0] result.setdefault(base_name, set()).add(rel_path) + # Shared fixtures are named by marker, not by a test function; each + # decorator must carry a string literal or its fixture would silently + # map to no tests + for use in _SHARED_YAML_USE_RE.finditer(content): + arg = _SHARED_YAML_ARG_RE.match(content, use.end()) + if arg is None: + line = content.count("\n", 0, use.start()) + 1 + raise ValueError( + f"{rel_path}:{line}: shared_yaml marker must take a " + "single-line string literal so CI test selection can map " + "its fixture" + ) + result.setdefault(arg.group(1), set()).add(rel_path) return {k: frozenset(v) for k, v in result.items()} diff --git a/tests/integration/README.md b/tests/integration/README.md index 790d9a3a11f..bee20409e80 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t - The fixture file must exist or the test will fail with a clear error message - The fixture automatically injects a dynamic port number into the API configuration +Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead +of the test-named file and compile it in a shared, hash-keyed build directory, so +the whole group pays one full compile and each test only a relink. The marker +argument must be a single-line string literal (CI test selection maps fixtures to +test files by scanning for it), and marked tests must hand the `yaml_config` +content to `run_compiled` unmodified. + ### Key Fixtures - `run_compiled` - Combines write, compile, and run operations into a single context manager diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 15c58608799..78e0b1a36c9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,17 +4,22 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress import fcntl +from functools import cache +import hashlib import logging import os from pathlib import Path import platform +import re +import shutil import signal import socket import subprocess import sys import tempfile +import time from typing import TextIO from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic @@ -23,7 +28,13 @@ import pytest_asyncio import esphome.config from esphome.core import CORE -from esphome.helpers import get_usable_cpu_count +from esphome.helpers import ( + get_usable_cpu_count, + read_file, + rmtree, + write_file, + write_file_if_changed, +) from esphome.platformio.toolchain import get_idedata from .const import ( @@ -56,6 +67,21 @@ import pty # not available on Windows pytest.register_assert_rewrite("tests.integration.entity_utils") +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "shared_yaml(name): load fixtures/.yaml and compile it in a shared, " + "hash-keyed incremental build directory", + ) + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +# CI caches parts of this path; keep in sync with ci.yml integration-tests. +INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" + + def _get_platformio_env(cache_dir: Path) -> dict[str, str]: """Get environment variables for PlatformIO with shared cache.""" env = os.environ.copy() @@ -78,7 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: ) # Compile with THIS tree's esphome sources, not wherever the venv's editable # install points (which may be a different git worktree or checkout). - repo_root = str(Path(__file__).resolve().parent.parent.parent) + repo_root = str(REPO_ROOT) existing = env.get("PYTHONPATH") env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env @@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: def shared_platformio_cache() -> Generator[Path]: """Initialize a shared PlatformIO cache for all integration tests.""" # Use a dedicated directory for integration tests to avoid conflicts. - # CI caches parts of this path; keep in sync with ci.yml integration-tests. - test_cache_dir = Path.home() / ".esphome-integration-tests" + test_cache_dir = INTEGRATION_TESTS_ROOT cache_dir = test_cache_dir / "platformio" # Use a lock file in the home directory to ensure only one process initializes the cache @@ -112,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]: init_dir = Path(tmpdir) fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" config_path = init_dir / "cache_init.yaml" - config_path.write_text(fixture_path.read_text()) + config_path.write_text( + fixture_path.read_text(encoding="utf-8"), encoding="utf-8" + ) # Run compilation to populate the cache # We must succeed here to avoid race conditions where multiple @@ -162,13 +189,6 @@ def integration_test_dir() -> Generator[Path]: yield Path(tmpdir) -@pytest.fixture -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Host preferences persist per device name; give the test its own so a - provisioned key never leaks into another run.""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) - - @pytest.fixture def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: """Reserve an unused TCP port by holding the socket open.""" @@ -188,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int: return reserved_tcp_port[0] +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Give every test its own host prefs dir; prefs are keyed only by device + name, which tests sharing a fixture also share.""" + prefdir = tmp_path / "prefs" + monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir)) + return prefdir + + @pytest_asyncio.fixture async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: """Load YAML configuration based on test name.""" - # Get the test function name - test_name: str = request.node.name - # Extract the base test name (remove test_ prefix and any parametrization) - base_name = test_name.replace("test_", "").partition("[")[0] + shared_name = _shared_yaml_name(request) + # Base test name: test_ prefix and any parametrization stripped + base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0] # Load the fixture file - fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml" + fixture_path = FIXTURES_DIR / f"{base_name}.yaml" if not fixture_path.exists(): raise FileNotFoundError(f"Fixture file not found: {fixture_path}") loop = asyncio.get_running_loop() - content = await loop.run_in_executor(None, fixture_path.read_text) + content = await loop.run_in_executor(None, read_file, fixture_path) # Replace the port in the config if it contains api section if "api:" in content: @@ -226,11 +254,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s # Replace external component path placeholder if present if "EXTERNAL_COMPONENT_PATH" in content: - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) + external_components_path = str(FIXTURES_DIR / "external_components") content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + if shared_name is not None: + # _compile verifies the marked test compiles this content unmodified + request.node._shared_yaml_content = content + return content @@ -240,24 +270,218 @@ async def write_yaml_config( ) -> AsyncGenerator[ConfigWriter]: """Write YAML configuration to a file.""" # Get the test name for default filename - test_name = request.node.name - base_name = test_name.replace("test_", "").split("[")[0] + base_name = request.node.name.replace("test_", "").partition("[")[0] async def _write_config(content: str, filename: str | None = None) -> Path: if filename is None: filename = f"{base_name}.yaml" config_path = integration_test_dir / filename loop = asyncio.get_running_loop() - await loop.run_in_executor(None, config_path.write_text, content) + await loop.run_in_executor(None, write_file, config_path, content) return config_path yield _write_config +# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale +# dirs for a fixture are pruned when its content hash changes. +SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds" + +# In the dir name (not just the hash) so pruning stays inside this checkout +_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8] + +# Give a contended shared build lock time for a full cold compile ahead of us +_SHARED_LOCK_TIMEOUT_S = 900 +_SHARED_LOCK_POLL_S = 0.1 +_SHARED_LOCK_REPORT_S = 30 + +# Reclaims dirs orphaned by fixture renames or deleted checkouts +_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600 + +# ELF path per shared build dir; constant once compiled, so resolve it only once +_shared_elf_paths: dict[Path, Path] = {} + +# Dirs this process already swept; pruning is session-scoped work +_pruned_dirs: set[Path] = set() + + +def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None: + """Name passed to the shared_yaml marker, or None when unmarked.""" + marker = request.node.get_closest_marker("shared_yaml") + if marker is None: + return None + # Exactly one \w+ positional arg: the name doubles as a build dir + # component, and CI test selection (script/helpers.py) parses the same shape + if ( + len(marker.args) != 1 + or marker.kwargs + or not re.fullmatch(r"\w+", str(marker.args[0])) + ): + raise ValueError( + "shared_yaml marker requires exactly one \\w+ fixture name literal" + ) + return marker.args[0] + + +def _shared_build_prefix(name: str) -> str: + return f"{name}-{_REPO_KEY}-" + + +@cache +def _shared_build_dir(name: str) -> Path: + """Dir keyed by checkout and fixture source, before per-test injections.""" + key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16] + return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key) + + +def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None: + """ELF path recorded by the last completed compile, or None.""" + try: + text = stamp.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + except OSError as err: + print(f"Cannot read {stamp}: {err}") + return None + if not text: + print(f"Ignoring empty stamp {stamp}") + return None + built = Path(text) + # Never trust a stamp pointing outside its own build dir as an unlink target + if shared_dir.resolve() in built.resolve().parents: + return built + print(f"Ignoring stamp {stamp} pointing outside {shared_dir}") + return None + + +def _unused_since(stale: Path, cutoff: float) -> bool: + """Whether a build dir looks untouched since cutoff; unknown counts as used.""" + # Newest of the .built stamp (rewritten by every completed compile) and the + # dir itself (freshened by a worker claiming the dir before locking) + newest: float | None = None + for probe in (stale / ".built", stale): + try: + mtime = probe.stat().st_mtime + except FileNotFoundError: + continue + except NotADirectoryError: + return True # a stray file where a dir should be; reclaimable + except OSError as err: + print(f"Cannot age-probe {stale}: {err}") + return False # unknown never authorizes deletion + newest = mtime if newest is None else max(newest, mtime) + return newest is not None and newest < cutoff + + +def _prune_stale_builds(name: str, keep: Path) -> None: + """Remove outdated build dirs (blocking, run in executor): this checkout's + other dirs for the fixture, plus anything untouched for 30 days. Tolerates + other workers pruning the same dirs concurrently.""" + cutoff = time.time() - _STALE_BUILD_MAX_AGE_S + prefix = _shared_build_prefix(name) + for stale in SHARED_BUILDS_ROOT.iterdir(): + if stale == keep: + continue + same_fixture = stale.name.startswith(prefix) + if not same_fixture and not _unused_since(stale, cutoff): + continue + # Creating .lock bumps the dir mtime, so remember whether the re-probe + # under the lock can trust it + lock_preexisting = (stale / ".lock").exists() + try: + lock_file = (stale / ".lock").open("w") + except FileNotFoundError: + continue # pruned by another worker meanwhile + except NotADirectoryError: + print(f"Removing stray file {stale}") + stale.unlink(missing_ok=True) + continue + except OSError as err: + print(f"Cannot prune {stale}: {err}") + continue + with lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + continue # still in use by another run + # Re-probe under the lock: a worker freshens its dir before + # locking, so a just-claimed dir no longer looks unused. A dir + # whose .lock we just created cannot be held by anyone, and our + # own open bumped its mtime, so its pre-open probe stands + if ( + lock_preexisting + and not same_fixture + and not _unused_since(stale, cutoff) + ): + continue + # rmtree tolerates races; a leftover partial tree only costs a + # rebuild, since the ELF is deleted before every compile + try: + rmtree(stale) + except OSError as err: + print(f"Failed to prune {stale}: {err}") + + +async def _run_esphome_compile( + config_path: Path, cwd: Path, env: dict[str, str] +) -> None: + """Run `esphome compile`, retrying up to 3 times on a segfault.""" + max_retries = 3 + for attempt in range(max_retries): + # Compile using subprocess, inheriting stdout/stderr to show progress + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "esphome", + "compile", + str(config_path), + cwd=cwd, + stdout=None, # Inherit stdout + stderr=None, # Inherit stderr + stdin=asyncio.subprocess.DEVNULL, + # Start in a new process group to isolate signal handling + start_new_session=True, + env=env, + close_fds=False, + ) + await proc.wait() + + if proc.returncode == 0: + break + if proc.returncode == -11 and attempt < max_retries - 1: + # Segfault (-11 = SIGSEGV), retry + print( + f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." + ) + await asyncio.sleep(1) # Brief pause before retry + continue + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) + + +def _resolve_compiled_binary(config_path: Path) -> Path: + """Load the config to learn the compiled ELF path (blocking, run in executor).""" + CORE.reset() # Reset CORE state between test runs + CORE.config_path = config_path + config = esphome.config.read_config( + {"command": "compile", "config": str(config_path)} + ) + if config is None: + raise RuntimeError(f"Failed to read config from {config_path}") + idedata = get_idedata(config) + binary_path = Path(idedata.firmware_elf_path) + if not binary_path.exists(): + raise RuntimeError(f"Compiled binary not found at {binary_path}") + return binary_path + + @pytest_asyncio.fixture async def compile_esphome( integration_test_dir: Path, shared_platformio_cache: Path, + request: pytest.FixtureRequest, ) -> AsyncGenerator[CompileFunction]: """Compile an ESPHome configuration and return the binary path.""" @@ -265,66 +489,96 @@ async def compile_esphome( # Use the shared PlatformIO cache for faster compilation # This avoids re-downloading dependencies for each test env = _get_platformio_env(shared_platformio_cache) - - # Retry compilation up to 3 times if we get a segfault - max_retries = 3 - for attempt in range(max_retries): - # Compile using subprocess, inheriting stdout/stderr to show progress - proc = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "esphome", - "compile", - str(config_path), - cwd=integration_test_dir, - stdout=None, # Inherit stdout - stderr=None, # Inherit stderr - stdin=asyncio.subprocess.DEVNULL, - # Start in a new process group to isolate signal handling - start_new_session=True, - env=env, - close_fds=False, - ) - await proc.wait() - - if proc.returncode == 0: - # Success! - break - if proc.returncode == -11 and attempt < max_retries - 1: - # Segfault (-11 = SIGSEGV), retry - print( - f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..." - ) - await asyncio.sleep(1) # Brief pause before retry - continue - # Other error or final retry - raise RuntimeError( - f"Failed to compile {config_path}, return code: {proc.returncode}. " - f"Run with 'pytest -s' to see compilation output." - ) - - # Load the config to get idedata (blocking call, must use executor) loop = asyncio.get_running_loop() - def _read_config_and_get_binary(): - CORE.reset() # Reset CORE state between test runs - CORE.config_path = config_path - config = esphome.config.read_config( - {"command": "compile", "config": str(config_path)} + name = _shared_yaml_name(request) + if name is None: + await _run_esphome_compile(config_path, integration_test_dir, env) + return await loop.run_in_executor( + None, _resolve_compiled_binary, config_path ) - if config is None: - raise RuntimeError(f"Failed to read config from {config_path}") - # Get the compiled binary path - idedata = get_idedata(config) - return Path(idedata.firmware_elf_path) - - binary_path = await loop.run_in_executor(None, _read_config_and_get_binary) - - if not binary_path.exists(): - raise RuntimeError(f"Compiled binary not found at {binary_path}") - - return binary_path + # Shared fixture: build in a hash-keyed dir so tests sharing a config + # pay one full compile and later only a main.cpp (port) rebuild + relink + shared_dir = _shared_build_dir(name) + shared_dir.mkdir(parents=True, exist_ok=True) + # Freshen the dir before locking so a concurrent age sweep, which + # re-probes under the lock, never reaps a dir a worker just claimed; + # if a peer reaped it already, the guarded lock open recreates it + with suppress(FileNotFoundError): + os.utime(shared_dir) + if shared_dir not in _pruned_dirs: + _pruned_dirs.add(shared_dir) + await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir) + shared_config = shared_dir / f"{name}.yaml" + private_binary = integration_test_dir / f"{name}.elf" + content = await loop.run_in_executor(None, read_file, config_path) + if content != getattr(request.node, "_shared_yaml_content", None): + # The dir is keyed by the fixture source; a mutated config would be + # cached under a hash that does not describe it + raise RuntimeError( + "shared_yaml tests must compile the yaml_config content unmodified" + ) + # flock serializes concurrent xdist workers; closing the fd releases it. + # Hand-rolled rather than filelock.FileLock: non-blocking retries keep + # the wait cancellable, while a blocking acquire in an executor thread + # would survive test cancellation holding the fd + try: + lock_file = (shared_dir / ".lock").open("w") + except FileNotFoundError: + # A peer run pruning divergent hashes reaped the dir between our + # mkdir and this open; recreate it and pay a full rebuild + shared_dir.mkdir(parents=True, exist_ok=True) + lock_file = (shared_dir / ".lock").open("w") + with lock_file: + start = time.monotonic() + last_report = start + while True: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + now = time.monotonic() + if now - start > _SHARED_LOCK_TIMEOUT_S: + raise RuntimeError( + f"Timed out waiting for the {shared_dir} lock" + ) from None + if now - last_report >= _SHARED_LOCK_REPORT_S: + last_report = now + print( + f"Waited {now - start:.0f}s for another worker's " + f"build of {shared_dir.name}" + ) + await asyncio.sleep(_SHARED_LOCK_POLL_S) + # .built carries the ELF path of the last completed compile, so + # later workers skip the config re-read in _resolve_compiled_binary + stamp = shared_dir / ".built" + if (built := _shared_elf_paths.get(shared_dir)) is None: + built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir) + # Delete the ELF before compiling: whatever exists afterwards is + # this compile's output, so no staleness check is ever needed. + # With no usable stamp, sweep any leftover at the known layout + if built is not None: + built.unlink(missing_ok=True) + else: + # Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree + for leftover in shared_dir.rglob("program"): + if leftover.is_file(): + leftover.unlink() + await loop.run_in_executor( + None, write_file_if_changed, shared_config, content + ) + await _run_esphome_compile(shared_config, shared_dir, env) + if built is None or not built.exists(): + built = await loop.run_in_executor( + None, _resolve_compiled_binary, shared_config + ) + _shared_elf_paths[shared_dir] = built + await loop.run_in_executor(None, write_file, stamp, str(built)) + # Copy out before unlocking: another worker may relink firmware.elf + # while this test is still running its private copy + await loop.run_in_executor(None, shutil.copy2, built, private_binary) + return private_binary yield _compile diff --git a/tests/integration/fixtures/sensor_filters_batch_window.yaml b/tests/integration/fixtures/sensor_filters_batch_window.yaml deleted file mode 100644 index 58a254c215c..00000000000 --- a/tests/integration/fixtures/sensor_filters_batch_window.yaml +++ /dev/null @@ -1,58 +0,0 @@ -esphome: - name: test-batch-window-filters - -host: -api: - batch_delay: 0ms # Disable batching to receive all state updates -logger: - level: DEBUG - -# Template sensor that we'll use to publish values -sensor: - - platform: template - name: "Source Sensor" - id: source_sensor - accuracy_decimals: 2 - - # Batch window filters (window_size == send_every) - use streaming filters - - platform: copy - source_id: source_sensor - name: "Min Sensor" - id: min_sensor - filters: - - min: - window_size: 5 - send_every: 5 - send_first_at: 1 - - - platform: copy - source_id: source_sensor - name: "Max Sensor" - id: max_sensor - filters: - - max: - window_size: 5 - send_every: 5 - send_first_at: 1 - - - platform: copy - source_id: source_sensor - name: "Moving Avg Sensor" - id: moving_avg_sensor - filters: - - sliding_window_moving_average: - window_size: 5 - send_every: 5 - send_first_at: 1 - -# Button to trigger publishing test values -button: - - platform: template - name: "Publish Values Button" - id: publish_button - on_press: - - lambda: |- - // Publish 10 values: 1.0, 2.0, ..., 10.0 - for (int i = 1; i <= 10; i++) { - id(source_sensor).publish_state(float(i)); - } diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml deleted file mode 100644 index 1f89889c958..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml +++ /dev/null @@ -1,111 +0,0 @@ -esphome: - name: uart-mock-modbus-cli-rw - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -# Two virtual buses looped back to each other: the client's transmissions reach the server and the -# server's replies reach the client. auto_start so forwarding is active before the button fires. -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_client - data: !lambda return data; - - id: virtual_uart_client - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_1 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_client - id: virtual_modbus_client - role: client - turnaround_time: 10ms - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - registers: - # Writable + readable register: the read publishes what it returns, so the test can confirm the - # write half of the 0x17 ran before the read half (Modbus 6.17). - - address: 0x01 - value_type: U_WORD - read_lambda: |- - id(srv_read_1).publish_state(id(stored_1)); - return id(stored_1); - write_lambda: |- - id(stored_1) = x; - id(srv_write_1).publish_state(x); - return true; - # Read-only register, returned together with 0x01 by the 2-register read half. - - address: 0x02 - value_type: U_WORD - read_lambda: return 0x00AA; - -sensor: - # Server-side observations. - - platform: template - name: "srv_write_1" - id: srv_write_1 - - platform: template - name: "srv_read_1" - id: srv_read_1 - # Client-side read-back: the values the client's on_response received. - - platform: template - name: "client_read_0" - id: client_read_0 - - platform: template - name: "client_read_1" - id: client_read_1 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - on_press: - # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. - - modbus_client.read_write_multiple_registers: - address: 0x01 - read_address: 0x0001 - read_count: 2 - write_address: 0x0001 - values: [0x1234] - on_response: - then: - - lambda: |- - // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. - if (values.size() >= 2) { - id(client_read_0).publish_state(values[0]); - id(client_read_1).publish_state(values[1]); - } diff --git a/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml b/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml deleted file mode 100644 index 188abf90f18..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_custom_pdu.yaml +++ /dev/null @@ -1,88 +0,0 @@ -esphome: - name: uart-mock-modbus-custom-pdu - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 259; - -sensor: - # Plain read to confirm the controller <-> server link is up. - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "plain_read" - address: 0x01 - register_type: holding - value_type: U_WORD - # Custom PDU: read holding register 0x0001, count 1. The PDU is - # {function code, address hi, address lo, count hi, count lo}; the device - # address and CRC are added by the hub. The lambda parses the response payload - # (the register value, big-endian). - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "custom_read" - custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01] - lambda: |- - if (data.size() < 2) return {}; - return (float) ((data[0] << 8) | data[1]); - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml b/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml deleted file mode 100644 index f378e3de433..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml +++ /dev/null @@ -1,106 +0,0 @@ -esphome: - name: uart-mock-modbus-dep-buffer - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg10 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x10 - value_type: U_WORD - read_lambda: return id(reg10); - write_lambda: |- - id(reg10) = x; - return true; - -# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw -# frame as words: device address + function code + data) instead of the new item->write_* API. The write -# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per -# entity no matter how many writes happen. -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "buf_number" - id: buf_number - address: 0x10 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 1000 - step: 1 - write_lambda: |- - // Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value. - payload.push_back(0x0106); - payload.push_back(0x0010); - payload.push_back((uint16_t) x); - return {}; - -# Reports the server-side register so the test can observe that the deprecated buffer write landed. -sensor: - - platform: template - name: "written_value" - id: written_value - update_interval: 0.5s - lambda: "return id(reg10);" - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # The test drives the writes via number_command; the mock is autostart. diff --git a/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml b/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml deleted file mode 100644 index 41afce70d6e..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_lambda_invert.yaml +++ /dev/null @@ -1,95 +0,0 @@ -esphome: - name: uart-mock-modbus-lambda-invert - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg40 - type: uint16_t - initial_value: "5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x40 - value_type: U_WORD - read_lambda: return id(reg40); - write_lambda: id(reg40) = x; return true; - -# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still -# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes -# only from write_state() - turning ON writes 0x0000 yet the switch shows ON. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "invert_switch" - register_type: holding - address: 0x40 - assumed_state: true - write_lambda: |- - return !x; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_40" - address: 0x40 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml b/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml deleted file mode 100644 index 86e17ea0d73..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml +++ /dev/null @@ -1,97 +0,0 @@ -esphome: - name: uart-mock-modbus-lambda-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg30 - type: uint16_t - initial_value: "0" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x30 - value_type: U_WORD - read_lambda: return id(reg30); - write_lambda: id(reg30) = x; return true; - -# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead -# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so -# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing -# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "cross_switch" - register_type: coil - address: 0x00 - assumed_state: true - write_lambda: |- - item->write_single_register(0x30, x ? 1234 : 0); - return {}; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_30" - address: 0x30 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_loopback.yaml b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml new file mode 100644 index 00000000000..7212bfb2b24 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_loopback.yaml @@ -0,0 +1,233 @@ +esphome: + name: uart-mock-modbus-loopback + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Shared loopback fixture (see the shared_yaml markers in the test file); +# register spaces are disjoint so each test only observes its own entities. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: reg10 + type: uint16_t + initial_value: "100" + - id: reg11 + type: uint16_t + initial_value: "200" + - id: reg12 + type: uint16_t + initial_value: "300" + - id: reg13 + type: uint16_t + initial_value: "0xABCD" + - id: reg30 + type: uint16_t + initial_value: "0" + - id: reg40 + type: uint16_t + initial_value: "5" + - id: reg50 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 259; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: id(reg10) = x; return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: id(reg11) = x; return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: id(reg12) = x; return true; + - address: 0x13 + value_type: U_WORD + read_lambda: return id(reg13); + - address: 0x30 + value_type: U_WORD + read_lambda: return id(reg30); + write_lambda: id(reg30) = x; return true; + - address: 0x40 + value_type: U_WORD + read_lambda: return id(reg40); + write_lambda: id(reg40) = x; return true; + - address: 0x50 + value_type: U_WORD + read_lambda: return id(reg50); + write_lambda: id(reg50) = x; return true; + +# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a +# register count, hitting 0x12). assumed_state keeps the switch write-only. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "offset_switch" + register_type: holding + address: 0x10 + offset: 2 + assumed_state: true + # Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16) + # would draw ILLEGAL_DATA_ADDRESS and never publish. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "read_offset_switch" + register_type: holding + address: 0x10 + offset: 6 + bitmask: 0x1 + # Coil switch whose write_lambda dispatches a holding-register write via `item`; + # returning an empty optional suppresses the default coil write. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "cross_switch" + register_type: coil + address: 0x00 + assumed_state: true + write_lambda: |- + item->write_single_register(0x30, x ? 1234 : 0); + return {}; + # Active-low: the write_lambda inverts the wire value but the entity must still + # report the requested state (assumed_state keeps the register unpolled). + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "invert_switch" + register_type: holding + address: 0x40 + assumed_state: true + write_lambda: |- + return !x; + +# Uses the deprecated buffer parameter (legacy raw frame as words); the write +# must land and the deprecation warning must fire only once per entity. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "buf_number" + id: buf_number + address: 0x50 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 1000 + step: 1 + write_lambda: |- + // Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value. + payload.push_back(0x0106); + payload.push_back(0x0050); + payload.push_back((uint16_t) x); + return {}; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "plain_read" + address: 0x01 + register_type: holding + value_type: U_WORD + # Custom PDU: read holding register 0x0001; device address and CRC are added + # by the hub. The lambda parses the big-endian register value. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "custom_read" + custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01] + lambda: |- + if (data.size() < 2) return {}; + return (float) ((data[0] << 8) | data[1]); + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_10" + address: 0x10 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_11" + address: 0x11 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_12" + address: 0x12 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_30" + address: 0x30 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_40" + address: 0x40 + register_type: holding + value_type: U_WORD + # Reports the server-side register so the test can observe that the deprecated buffer write landed. + - platform: template + name: "written_value" + id: written_value + update_interval: 0.5s + lambda: "return id(reg50);" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # Nothing to start (mock is autostart); tests drive entities directly diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml similarity index 58% rename from tests/integration/fixtures/uart_mock_modbus_server_controller.yaml rename to tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 4a5d280a2f0..69edd614d7a 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -1,5 +1,5 @@ esphome: - name: uart-mock-modbus-server-contro + name: uart-mock-modbus-mesh host: api: @@ -17,13 +17,14 @@ uart: baud_rate: 115200 port: /dev/null +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only +# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second +# server hub. auto_start everywhere: the controller polls at boot, so the +# forwarding must already be live or early requests generate warnings. +# Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. auto_start: true debug: on_tx: @@ -31,35 +32,68 @@ uart_mock: - uart_mock.inject_rx: id: virtual_uart_controller data: !lambda return data; - - id: virtual_uart_controller + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true debug: on_tx: - then: - uart_mock.inject_rx: id: virtual_uart_server data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" modbus: - uart_id: virtual_uart_server id: virtual_modbus_server role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server - uart_id: virtual_uart_controller - id: virtual_modbus_controller + id: virtual_modbus_client role: client turnaround_time: 10ms modbus_controller: - address: 1 - modbus_id: virtual_modbus_controller + modbus_id: virtual_modbus_client id: modbus_controller_1 update_interval: 1s + - address: 2 + modbus_id: virtual_modbus_client + id: modbus_controller_2 + update_interval: 1s + - address: 3 + modbus_id: virtual_modbus_client + id: modbus_controller_3 + update_interval: 1s modbus_server: - address: 1 modbus_id: virtual_modbus_server - id: modbus_server_1 registers: - address: 0x01 value_type: U_WORD @@ -103,6 +137,34 @@ modbus_server: - address: 0x28 value_type: FP32_R read_lambda: return 3.14; + - address: 5 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: srv_write_1 plus the client's read-back + # confirm the write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 3 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 929; sensor: - platform: modbus_controller @@ -195,9 +257,46 @@ sensor: address: 0x28 register_type: holding value_type: FP32_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: "multi_reg_a" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_3 + name: "multi_reg_b" + address: 0x01 + register_type: holding + value_type: U_WORD + # client_read_write observations, server- and client-side. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 button: - platform: template name: "Start Scenario" id: start_scenario_btn - # This test does not have anything to start (mock is autostart) + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 5 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml b/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml deleted file mode 100644 index 21c451aa992..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml +++ /dev/null @@ -1,138 +0,0 @@ -esphome: - name: uart-mock-modbus-reg-offset - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: reg10 - type: uint16_t - initial_value: "100" - - id: reg11 - type: uint16_t - initial_value: "200" - - id: reg12 - type: uint16_t - initial_value: "300" - - id: reg13 - type: uint16_t - initial_value: "0xABCD" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - id: modbus_controller_1 - update_interval: 1s - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x10 - value_type: U_WORD - read_lambda: return id(reg10); - write_lambda: id(reg10) = x; return true; - - address: 0x11 - value_type: U_WORD - read_lambda: return id(reg11); - write_lambda: id(reg11) = x; return true; - - address: 0x12 - value_type: U_WORD - read_lambda: return id(reg12); - write_lambda: id(reg12) = x; return true; - - address: 0x13 - value_type: U_WORD - read_lambda: return id(reg13); - write_lambda: id(reg13) = x; return true; - -# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target -# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register -# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "offset_switch" - register_type: holding - address: 0x10 - offset: 2 - assumed_state: true - # A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix - # the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and - # joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds - # into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes. - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "read_offset_switch" - register_type: holding - address: 0x10 - offset: 6 - bitmask: 0x1 - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_10" - address: 0x10 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_11" - address: 0x11 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_12" - address: 0x12 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml deleted file mode 100644 index cc5a59e242a..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server.yaml +++ /dev/null @@ -1,124 +0,0 @@ -esphome: - name: uart-mock-modbus-server-test - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_dev - baud_rate: 9600 - rx_full_threshold: 120 - rx_timeout: 2 - auto_start: false - debug: - injections: - - delay: 100ms - inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) - - delay: 100ms - # Read holding register 7 on device 2 - # Reply from device 2 - # Read holding register 5 on device 1 (read_after_peer_response) - inject_rx: - [ - 0x02, - 0x03, - 0x00, - 0x07, - 0x00, - 0x01, - 0x35, - 0xF8, - 0x02, - 0x03, - 0x02, - 0x00, - 0xF0, - 0xFC, - 0x00, - 0x01, - 0x03, - 0x00, - 0x05, - 0x00, - 0x01, - 0x94, - 0x0B, - ] - - delay: 100ms - inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response - - delay: 100ms - # Read holding register 7 on device 2, with no response - # Read holding register A on device 1 (read_after_peer_timeout) - inject_rx: - [ - 0x02, - 0x03, - 0x00, - 0x07, - 0x00, - 0x01, - 0x35, - 0xF8, - 0x01, - 0x03, - 0x00, - 0x0A, - 0x00, - 0x01, - 0xA4, - 0x08, - ] - -modbus: - uart_id: virtual_uart_dev - role: server - -modbus_server: - - address: 1 - registers: - - address: 0x03 - value_type: U_WORD - read_lambda: |- - id(basic_read).publish_state(1); - return 1; - - address: 0x05 - value_type: U_WORD - read_lambda: |- - id(read_after_peer_response).publish_state(1); - return 1; - - address: 0x0A - value_type: U_WORD - read_lambda: |- - id(read_after_peer_timeout).publish_state(1); - return 1; - -sensor: - - platform: template - name: "basic_read" - id: basic_read - - platform: template - name: "read_after_peer_response" - id: read_after_peer_response - - platform: template - name: "read_after_peer_timeout" - id: read_after_peer_timeout - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml deleted file mode 100644 index 18423be6d58..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ /dev/null @@ -1,116 +0,0 @@ -esphome: - name: uart-mock-modbus-server-mult - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_server_2 - data: !lambda return data; - - id: virtual_uart_server_2 - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - - uart_mock.inject_rx: - id: virtual_uart_server_2 - data: !lambda return data; - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_server_2 - id: virtual_modbus_server_2 - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_client - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_client - update_interval: 1s - id: modbus_controller_1 - - address: 2 - modbus_id: virtual_modbus_client - update_interval: 1s - id: modbus_controller_2 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 919; - - address: 2 - modbus_id: virtual_modbus_server_2 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return 929; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_2 - name: "reg_u_word_2" - address: 0x01 - register_type: holding - value_type: U_WORD - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml similarity index 52% rename from tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml rename to tests/integration/fixtures/uart_mock_modbus_server_injected.yaml index e998861c2d3..2cd1c610f19 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_read_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -1,5 +1,5 @@ esphome: - name: uart-mock-modbus-srv-rw + name: uart-mock-modbus-srv-injected host: api: @@ -17,6 +17,8 @@ uart: baud_rate: 115200 port: /dev/null +# Shared server-role fixture (see the shared_yaml markers in the test file); +# the injections concatenate and each test waits only on its own sensors. uart_mock: - id: virtual_uart_dev baud_rate: 9600 @@ -25,18 +27,31 @@ uart_mock: auto_start: false debug: injections: - # FC 0x17 Read/Write Multiple Registers on device 1: - # write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2). - # Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must - # read back the just-written 0x1234 in the same request. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) + - delay: 100ms + # Read holding register 7 on device 2, its reply, then read holding + # register 5 on device 1 (read_after_peer_response) + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8, + 0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC, + 0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B] + - delay: 100ms + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response + - delay: 100ms + # Read holding register 7 on device 2 with no response, then read + # holding register A on device 1 (read_after_peer_timeout) + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8, + 0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] + # FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002; + # per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234. - delay: 100ms inject_rx: [0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8] - # FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) - + # FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) - # a write and read targeting a different register block. - delay: 100ms inject_rx: - [0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10] + [0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55] globals: - id: stored_1 @@ -70,8 +85,18 @@ modbus_server: read_lambda: |- id(rw_read_2).publish_state(0x00AA); return 0x00AA; - # Second writable + readable register, targeted by the second request. - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(basic_read).publish_state(1); + return 1; + - address: 0x05 + value_type: U_WORD + read_lambda: |- + id(read_after_peer_response).publish_state(1); + return 1; + # Second writable + readable register, targeted by the second FC 0x17 request. + - address: 0x06 value_type: U_WORD read_lambda: |- id(rw_read_3).publish_state(id(stored_3)); @@ -80,8 +105,22 @@ modbus_server: id(stored_3) = x; id(rw_write_3).publish_state(x); return true; + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(read_after_peer_timeout).publish_state(1); + return 1; sensor: + - platform: template + name: "basic_read" + id: basic_read + - platform: template + name: "read_after_peer_response" + id: read_after_peer_response + - platform: template + name: "read_after_peer_timeout" + id: read_after_peer_timeout - platform: template name: "rw_write_1" id: rw_write_1 diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a010..5f526dce5f4 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -1,7 +1,7 @@ """Helpers for manipulating the host platform's preferences file. ESPHome's host platform stores preferences in -``~/.esphome/prefs/.prefs`` using a simple binary layout that +``$ESPHOME_PREFDIR/.prefs`` using a simple binary layout that mirrors ``HostPreferences::sync()``: ``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry. @@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs. from __future__ import annotations +import os from pathlib import Path import struct def host_prefs_path(device_name: str) -> Path: - """Return the on-disk prefs file path for a host-platform device.""" - return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs" + """Return the on-disk prefs file path for a host-platform device. + + Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture + sets; refusing the ~/.esphome/prefs fallback keeps tests off real user + data if the fixture is ever bypassed.""" + prefdir = os.environ.get("ESPHOME_PREFDIR") + if not prefdir: + raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir") + return Path(prefdir) / f"{device_name}.prefs" def clear_host_prefs(device_name: str) -> None: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py index f315335d1bc..d103167a00c 100644 --- a/tests/integration/test_api_zero_psk_provisioning.py +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -24,7 +24,6 @@ from .types import ( RunCompiledFunction, ) -pytestmark = pytest.mark.usefixtures("isolated_preferences") NEW_KEY = PROVISIONING_PSK diff --git a/tests/integration/test_host_preferences_suspend_resume.py b/tests/integration/test_host_preferences_suspend_resume.py index ab08d5c4409..5f08d5519ed 100644 --- a/tests/integration/test_host_preferences_suspend_resume.py +++ b/tests/integration/test_host_preferences_suspend_resume.py @@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None: await asyncio.sleep(0.05) -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path: - """Keep host preferences per-test so this test never touches the real - ~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR.""" - prefdir = tmp_path / "prefs" - monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir)) - return prefdir / f"{DEVICE_NAME}.prefs" - - @pytest.mark.asyncio async def test_host_preferences_suspend_resume( yaml_config: str, @@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume( isolated_preferences: Path, ) -> None: """Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing.""" - pref_file = isolated_preferences + pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs" loop = asyncio.get_running_loop() saved_in_memory = loop.create_future() diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 657e273fe73..12ebf7c4a1a 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left - behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs, - keyed only by device name).""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) - - @pytest.mark.asyncio async def test_light_initial_state( yaml_config: str, diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 864275f5ed7..232e1fb6543 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -173,6 +173,7 @@ async def test_uart_mock_modbus_no_threshold( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") @pytest.mark.asyncio async def test_uart_mock_modbus_server( yaml_config: str, @@ -203,6 +204,7 @@ async def test_uart_mock_modbus_server( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") @pytest.mark.asyncio async def test_uart_mock_modbus_server_read_write( yaml_config: str, @@ -231,8 +233,8 @@ async def test_uart_mock_modbus_server_read_write( "rw_write_1": 4660, # 0x1234 written to reg 0x0001 "rw_read_1": 4660, # reg 0x0001 reads back the just-written value "rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request - "rw_write_3": 22136, # 0x5678 written to reg 0x0003 - "rw_read_3": 22136, # reg 0x0003 reads back the just-written value + "rw_write_3": 22136, # 0x5678 written to reg 0x0006 + "rw_read_3": 22136, # reg 0x0006 reads back the just-written value } ) @@ -241,7 +243,8 @@ async def test_uart_mock_modbus_server_read_write( api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) - await tracker.await_all(futures) + # The FC 0x17 injections fire last, behind four earlier 100ms delays + await tracker.await_all(futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -296,6 +299,7 @@ async def test_uart_mock_modbus_server_read_write_invalid( ) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller( yaml_config: str, @@ -485,6 +489,7 @@ async def test_uart_mock_modbus_server_controller_bits( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -495,7 +500,7 @@ async def test_uart_mock_modbus_server_controller_multiple( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = {"reg_u_word": 919, "reg_u_word_2": 929} + expected_values = {"multi_reg_a": 919, "multi_reg_b": 929} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -706,6 +711,7 @@ async def test_uart_mock_modbus_shared_address( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_custom_pdu( yaml_config: str, @@ -932,6 +938,7 @@ async def test_uart_mock_modbus_broadcast_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_client_read_write( yaml_config: str, @@ -947,9 +954,7 @@ async def test_uart_mock_modbus_client_read_write( """ line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - tracker = SensorTracker( - ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] - ) + tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"]) futures = tracker.expect_all( { "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 @@ -967,6 +972,7 @@ async def test_uart_mock_modbus_client_read_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_register_offset( yaml_config: str, @@ -1022,6 +1028,7 @@ async def test_uart_mock_modbus_register_offset( ) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_lambda_write( yaml_config: str, @@ -1058,6 +1065,7 @@ async def test_uart_mock_modbus_lambda_write( await tracker.await_change(wrote_30, "reg_30", timeout=4.0) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_lambda_invert( yaml_config: str, @@ -1113,6 +1121,7 @@ async def test_uart_mock_modbus_lambda_invert( ) +@pytest.mark.shared_yaml("uart_mock_modbus_loopback") @pytest.mark.asyncio async def test_uart_mock_modbus_deprecated_write_buffer( yaml_config: str, diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 7d4059da2f7..8f82a121c61 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -2122,6 +2122,34 @@ def test_get_cpp_changed_components_independent_of_cwd( ) == ["time"] +def test_fixture_map_includes_shared_yaml_markers() -> None: + """Fixtures named only by shared_yaml markers must map to their test file.""" + helpers.get_fixture_to_test_files.cache_clear() + mapping = helpers.get_fixture_to_test_files() + for fixture in ( + "uart_mock_modbus_loopback", + "uart_mock_modbus_mesh", + "uart_mock_modbus_server_injected", + ): + assert mapping[fixture] == frozenset( + {"tests/integration/test_uart_mock_modbus.py"} + ) + + +def test_no_orphan_integration_fixtures() -> None: + """Every fixture must reach CI test selection; an orphan selects nothing.""" + helpers.get_fixture_to_test_files.cache_clear() + mapping = helpers.get_fixture_to_test_files() + fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve() + fixtures = list(fixtures_dir.glob("*.yaml")) + assert fixtures, f"no fixtures found under {fixtures_dir}" + # cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead + orphans = [ + f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping + ] + assert not orphans, f"fixtures invisible to CI test selection: {orphans}" + + def test_lpt_partition_balances_skewed_weights() -> None: """Heavy items spread across groups instead of clustering.""" items = [f"i{n}" for n in range(6)] From 3926612281789284df820f21f159f8cf1bb24969 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 15:46:17 -0400 Subject: [PATCH 042/759] [core] Fix use-after-free when deleting a running StaticTask (#19048) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 4 +-- .../mixer/speaker/mixer_speaker.cpp | 4 +-- .../resampler/speaker/resampler_speaker.cpp | 4 +-- .../speaker/media_player/audio_pipeline.cpp | 11 +++++-- esphome/core/static_task.cpp | 30 ++++++++++++++----- esphome/core/static_task.h | 17 +++++++---- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 3dadb78077d..cebfe8e7914 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -446,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..0b79010773a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -382,8 +382,8 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); this->all_stopped_since_ms_ = 0; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index f1ebd180cc0..edda00ae061 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 010f0c50b33..c286a9d7d66 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() { if (!this->is_playing_) { // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks if (this->read_task_.is_created() || this->decode_task_.is_created()) { - this->read_task_.deallocate(); - this->decode_task_.deallocate(); + // Both are attempted every time; a task that is still running on the other core is freed by a + // subsequent call, and freeing an already freed task succeeds without doing anything + bool read_task_freed = this->read_task_.deallocate(); + bool decode_task_freed = this->decode_task_.deallocate(); + if (!read_task_freed || !decode_task_freed) { + // A task is still running on the other core, so keep the pipeline in its current state and try + // again on the next call + return AudioPipelineState::PLAYING; + } if (this->hard_stop_) { // Stop command was sent, so immediately end the playback this->speaker_->stop(); diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 4cfead44c29..43011083159 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return true; } -void StaticTask::destroy() { - if (this->handle_ != nullptr) { - TaskHandle_t handle = this->handle_; - this->handle_ = nullptr; - vTaskDelete(handle); +bool StaticTask::destroy() { + if (this->handle_ == nullptr) { + return true; } + + // Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks + // the other core to yield though, so the task may still be running on it for a moment. + vTaskSuspend(this->handle_); + if (eTaskGetState(this->handle_) != eSuspended) { + // The task is still running on the other core and using its stack. Deleting it now would only put it on + // the termination list and return, so the caller has to try again once it has been swapped out. + return false; + } + + // The task cannot run again, so the delete completes right away instead of being left to the idle task. + TaskHandle_t handle = this->handle_; + this->handle_ = nullptr; + vTaskDelete(handle); + return true; } -void StaticTask::deallocate() { - this->destroy(); +bool StaticTask::deallocate() { + if (!this->destroy()) { + return false; + } if (this->stack_buffer_ != nullptr) { RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); @@ -57,6 +72,7 @@ void StaticTask::deallocate() { this->stack_buffer_ = nullptr; this->stack_size_ = 0; } + return true; } } // namespace esphome diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 5fd5b38f9ef..e2996abedae 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -11,6 +11,7 @@ namespace esphome { /** Helper for FreeRTOS static task management. * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: @@ -23,7 +24,7 @@ class StaticTask { /// @brief Allocate stack and create task. /// @param fn Task function /// @param name Task name (for debug) - /// @param stack_size Stack size in StackType_t words + /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM @@ -31,11 +32,17 @@ class StaticTask { bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. - void destroy(); + /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is + /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. + /// @return true if the task was deleted; false if it is still running on another core, in which case the + /// caller should try again later. + bool destroy(); - /// @brief Delete the task (if running) and free the stack buffer. - void deallocate(); + /// @brief Delete the task (if created) and free the stack buffer. + /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// which case the caller should try again later. + bool deallocate(); protected: TaskHandle_t handle_{nullptr}; From 4ab9298ab3eedddbd45507785b4d2453ae867bba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:11:36 +0200 Subject: [PATCH 043/759] Bump esptool from 5.3.1 to 5.4.0 (#19023) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cd3f7446f35..dfddbed00bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.3 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.1 +esptool==5.4.0 click==8.3.3 aioesphomeapi==46.3.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 5bb112f407e8edac9576a7eea1eafb9d94cb1f47 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:04:34 -0400 Subject: [PATCH 044/759] [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) --- esphome/components/audio/audio_reader.cpp | 3 +++ esphome/components/audio/audio_transfer_buffer.cpp | 12 ++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../components/micro_wake_word/micro_wake_word.cpp | 2 +- esphome/components/microphone/microphone_source.h | 2 +- esphome/components/mixer/speaker/mixer_speaker.cpp | 12 ++++++------ .../resampler/speaker/resampler_speaker.cpp | 6 +++--- .../speaker/media_player/audio_pipeline.cpp | 12 +++++++----- 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1c2eb129046..b78a151ee42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -218,8 +218,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cebfe8e7914..cf239be6960 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -129,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // to drain it - reset() is a consumer operation and must run on the inference task's thread. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0b79010773a..ef21da65c5a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index edda00ae061..16d2d5dc9e2 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); } else { @@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } } @@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { this_resampler->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index c286a9d7d66..509984cfa29 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -322,17 +322,17 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { - reader->add_sink(this_pipeline->raw_file_ring_buffer_); + err = reader->add_sink(temp_ring_buffer); } } @@ -403,7 +403,9 @@ void AudioPipeline::decode_task(void *params) { make_unique(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); - decoder->add_source(this_pipeline->raw_file_ring_buffer_); + if (err == ESP_OK) { + err = decoder->add_source(this_pipeline->raw_file_ring_buffer_); + } if (err != ESP_OK) { // Send specific error message From 006f31af9308fd85212cec8b5a9816273608dbba Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:05:02 -0400 Subject: [PATCH 045/759] [i2s_audio] Fix spurious driver failure (#19045) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index b78a151ee42..1382a870465 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough From 8f511a365a471d1614e7578a03ceb3c0dbc4470f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:07:06 +0200 Subject: [PATCH 046/759] [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 4de706120e1..d17ebf235e5 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.24") + cg.add_library("esphome/noise-c", "0.1.26") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.6") + cg.add_library("esphome/libsodium", "1.10021.8") # 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 779a05e7de4..738773d1b56 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.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.24 ; used by noise (api, ota) + esphome/noise-c@0.1.26 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 4f7f5a4a4c8..00f22ca1389 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.24"] + assert libs == ["esphome/noise-c @ 0.1.26"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.24", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 0.1.26", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.24"] + assert cls.calls == ["esphome/noise-c @ 0.1.26"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.24"] is None + assert compats["esphome/noise-c @ 0.1.26"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 14c52dda8d5..b03bff19a27 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 6c5ab89d5f818ac501855479ea776984c5d3f16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:08:16 +0200 Subject: [PATCH 047/759] [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) --- esphome/components/esphome/ota/ota_esphome.cpp | 5 ++++- esphome/espota2.py | 9 ++++++--- tests/unit_tests/test_espota2.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1005ed214b6..f853ed6a2db 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/espota2.py b/esphome/espota2.py index ce403c398db..c683ffa323c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # across the addresses on top of that. EXTRA_UPLOAD_ATTEMPTS = 2 UPLOAD_RETRY_DELAY = 5.0 +# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA +# (105 s) so a stalled session is gone before a retry, and long enough for lwIP +# to get a lost chunk ack through after the retransmit run seen in practice +DATA_PHASE_TIMEOUT = 160.0 _LOGGER = logging.getLogger(__name__) @@ -694,8 +698,7 @@ def perform_ota( _LOGGER.info("Handshake complete") - # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures - sock.settimeout(90.0) + sock.settimeout(DATA_PHASE_TIMEOUT) if extended_proto: send_check(sock, ota_type, "ota type") @@ -854,7 +857,7 @@ def run_ota_impl_( # 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 + # 105s 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 = "" diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 8867e2c215b..2d65e8e0798 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,9 @@ def test_perform_ota_no_auth( "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" in caplog.text ) + # The data phase timeout must outlast the device's 105 s data timeout + mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT) + assert espota2.DATA_PHASE_TIMEOUT > 105.0 @pytest.mark.usefixtures("mock_time") From b947094f45f7bc8b193db6a75b732c9bdbcce41b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:31:18 -0400 Subject: [PATCH 048/759] [sendspin] Add codec preference list to the media source (#19047) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sendspin/__init__.py | 26 ++++-- .../sendspin/media_source/__init__.py | 31 +++++++ .../sendspin/test_media_source.py | 90 +++++++++++++++++++ .../sendspin/common-media_source.yaml | 1 + 4 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/sendspin/test_media_source.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3faddd..8ef11a7f909 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) - # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate - # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - # OPUS only supports 48 kHz audio - codecs = [CODEC_FORMAT_FLAC] - if sample_rate == 48000: - codecs.append(CODEC_FORMAT_OPUS) - codecs.append(CODEC_FORMAT_PCM) + codecs = player_cfg[CONF_CODECS] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f0..6a9f1f18ba2 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_( ) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config + + def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 00000000000..6c2f79198dd --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..0c136fbd43d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,4 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + codecs: [pcm, opus, flac] From 823d79c948eb4474423200d5a251210c31482b68 Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:10:55 +0200 Subject: [PATCH 049/759] [i2s_audio] Keep a start request that arrives while the speaker task stops (#19027) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/i2s_audio/speaker/i2s_audio_speaker.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 5e271e671e5..1c2eb129046 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -91,7 +91,14 @@ void I2SAudioSpeakerBase::loop() { this->speaker_task_handle_ = nullptr; this->stop_i2s_driver_(); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } this->status_clear_error(); this->on_task_stopped(); From 628ebe23ec389d770e822f18de22753c167dff6f Mon Sep 17 00:00:00 2001 From: mipa87 <62723159+mipa87@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:13:03 +0200 Subject: [PATCH 050/759] [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028) Co-authored-by: Claude --- esphome/components/audio/audio_decoder.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index fe9ad9c9add..051395606c8 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() { this->output_transfer_buffer_->increase_buffer_length( this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); } - } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { - // First successful header parse: capture stream info and resize the output buffer to fit one full frame. - // microMP3 always outputs 16-bit PCM. + } else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) { + // Header parsed: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its + // negative value it is documented as recoverable, so it must not reach the catch-all below. this->audio_stream_info_ = audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); this->free_buffer_required_ = From e7f45a0d315442dcf789997d6e28de72f082e28a Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:09:02 -0400 Subject: [PATCH 051/759] [mqtt] Restore brightness flag in light discovery (#18950) --- esphome/components/mqtt/mqtt_light.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index aa47bdf996a..a8b52a3839f 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -67,6 +67,9 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE)) color_modes.add(ESPHOME_F("rgbww")); + if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) + root[ESPHOME_F("brightness")] = true; + if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) || traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) { root[MQTT_MIN_MIREDS] = traits.get_min_mireds(); From d5cff6e9dfcdfce156483eecf205e64169a56dee Mon Sep 17 00:00:00 2001 From: AndreKR Date: Tue, 8 Sep 2026 03:13:51 +0200 Subject: [PATCH 052/759] [logger] Fix garbled stack traces (#17939) --- esphome/components/logger/logger_esp32.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 05fc959ceb2..c3d777299d9 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -5,6 +5,7 @@ #include #include +#include #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include @@ -76,7 +77,11 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_config.parity = UART_PARITY_DISABLE; uart_config.stop_bits = UART_STOP_BITS_1; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; +#if SOC_UART_SUPPORT_XTAL_CLK + uart_config.source_clk = UART_SCLK_XTAL; +#else uart_config.source_clk = UART_SCLK_DEFAULT; +#endif uart_param_config(uart_num, &uart_config); // The logger only writes to UART, never reads, so use the minimum RX buffer. // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). From 934086365217965f95c21bda0593b3f2ec960615 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 7 Sep 2026 18:27:49 -0700 Subject: [PATCH 053/759] [dallas_temp] filter 85 temp from sensor reset (#17877) Co-authored-by: Samuel Sieb --- esphome/components/dallas_temp/dallas_temp.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index ab4a8c458fe..c418362ced1 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -6,6 +6,7 @@ namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; +static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE; static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E; @@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() { default: break; } - + // undocumented test for powerup measurement of 85 + // https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem + if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) { + if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) { + ESP_LOGD(TAG, "dropping reading caused by sensor reset"); + return NAN; + } + } return temp / 16.0f; } From 199acdf5222a923d5c7951af2e0ea632f45ba220 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 7 Sep 2026 18:57:50 -0700 Subject: [PATCH 054/759] [ble_client] Report Established from nodes that never read services (#17920) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/ble_client/automation.h | 34 +++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 94eeb83b3eb..93aae23b6a1 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -22,6 +22,23 @@ class Automation { static const char *const TAG; }; +// Base for nodes that never read the parent's services. +// The parent releases its services only once every node reports Established, so a node that never +// reports it keeps that memory allocated for the life of the connection. +class BLEClientServicelessNode : public BLEClientNode { + public: + // Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event. + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final { + if (event == ESP_GATTC_SEARCH_CMPL_EVT) + this->node_state = espbt::ClientState::ESTABLISHED; + this->on_gattc_event(event, gattc_if, param); + } + + protected: + // Derived nodes handle GATT events here rather than by overriding the handler above. + virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {} +}; + // implement on_connect automation. class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: @@ -61,7 +78,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode } }; -class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +88,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN } }; -class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +99,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger, publ } }; -class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -315,19 +332,17 @@ template class BLEClientRemoveBondAction final : public Action class BLEClientConnectAction final : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { case ESP_GATTC_SEARCH_CMPL_EVT: - this->node_state = espbt::ClientState::ESTABLISHED; this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); }); break; // if the connection is closed, terminate the automation chain. @@ -364,14 +379,13 @@ template class BLEClientConnectAction final : public Action var_{}; }; -template class BLEClientDisconnectAction final : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { From 442e4a1ec2c70bfc8507aa1cf5f471402806d9b0 Mon Sep 17 00:00:00 2001 From: Davide D M Date: Tue, 8 Sep 2026 03:59:05 +0200 Subject: [PATCH 055/759] [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) --- esphome/components/debug/debug_esp32.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 969cd840cf6..8e1a67224eb 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -66,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; - if (pref.load(&reboot_source)) { + if (pref.load(&reboot_source) && reboot_source[0] != '\0') { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; snprintf(buf, size, "Reboot request from %s", reboot_source); } else { From c9729244af79e5b36a2b712c2fa5b91efa6a504f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:17:22 +1200 Subject: [PATCH 056/759] [udp] Use cv.invalid for relocated packet_transport options (#19032) --- esphome/components/udp/__init__.py | 16 +++------ tests/unit_tests/components/udp/__init__.py | 0 tests/unit_tests/components/udp/test_init.py | 37 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 tests/unit_tests/components/udp/__init__.py create mode 100644 tests/unit_tests/components/udp/test_init.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index a782d875b9d..d96a731e9c0 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,5 +1,4 @@ -from collections.abc import Callable -from typing import Any, NoReturn +from typing import Any from esphome import automation from esphome.automation import Trigger @@ -48,17 +47,10 @@ UDP_SCHEMA = cv.Schema( ) -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" - ) - - return validator - - RELOCATED = { - cv.Optional(x): is_relocated(x) + cv.Optional(x): cv.invalid( + f"The '{x}' option should now be configured in the 'packet_transport' component" + ) for x in ( CONF_PROVIDERS, CONF_ENCRYPTION, diff --git a/tests/unit_tests/components/udp/__init__.py b/tests/unit_tests/components/udp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/udp/test_init.py b/tests/unit_tests/components/udp/test_init.py new file mode 100644 index 00000000000..5afc92e9c67 --- /dev/null +++ b/tests/unit_tests/components/udp/test_init.py @@ -0,0 +1,37 @@ +"""Tests for the udp component configuration schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components import udp +from esphome.components.packet_transport import ( + CONF_BINARY_SENSORS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_PROVIDERS, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, +) +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + "option", + [ + CONF_PROVIDERS, + CONF_ENCRYPTION, + CONF_PING_PONG_ENABLE, + CONF_ROLLING_CODE_ENABLE, + CONF_SENSORS, + CONF_BINARY_SENSORS, + ], +) +def test_relocated_option_rejected(option: str) -> None: + """Options that moved to packet_transport raise a pointing error.""" + with pytest.raises(cv.Invalid) as exc_info: + udp.CONFIG_SCHEMA({option: True}) + assert ( + f"The '{option}' option should now be configured in the 'packet_transport' component" + in str(exc_info.value) + ) From ca864c22b4c0810e9e4779bfa6d95b3597cc8abe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:36:04 -0400 Subject: [PATCH 057/759] [tuya] Build without a network component (#18948) --- esphome/components/tuya/tuya.cpp | 17 +++++++++-- .../tuya/test-no-network.bk72xx-ard.yaml | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/test-no-network.bk72xx-ard.yaml diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 82fb96d7879..f9b4fe24532 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -1,10 +1,13 @@ #include "tuya.h" -#include "esphome/components/network/util.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -22,6 +25,14 @@ static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static bool network_is_connected() { +#ifdef USE_NETWORK + return network::is_connected(); +#else + return false; +#endif +} + void Tuya::setup() { this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { @@ -554,14 +565,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) { } void Tuya::set_status_pin_() { - bool is_network_ready = network::is_connected() && remote_is_connected(); + bool is_network_ready = network_is_connected() && remote_is_connected(); this->status_pin_->digital_write(is_network_ready); } uint8_t Tuya::get_wifi_status_code_() { uint8_t status = 0x02; - if (network::is_connected()) { + if (network_is_connected()) { status = 0x03; // Protocol version 3 also supports specifying when connected to "the cloud" diff --git a/tests/components/tuya/test-no-network.bk72xx-ard.yaml b/tests/components/tuya/test-no-network.bk72xx-ard.yaml new file mode 100644 index 00000000000..64207e94e38 --- /dev/null +++ b/tests/components/tuya/test-no-network.bk72xx-ard.yaml @@ -0,0 +1,29 @@ +# Tuya without any network component (no wifi/ethernet/api), as used on +# serial-only or BLE-only Tuya MCU boards. Regression test for +# https://github.com/esphome/esphome/issues/18942 +substitutions: + status_pin: P6 + +packages: + uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml + +tuya: + status_pin: ${status_pin} + +binary_sensor: + - platform: tuya + id: tuya_presence + sensor_datapoint: 101 + +sensor: + - platform: tuya + id: tuya_light_intensity + sensor_datapoint: 103 + +number: + - platform: tuya + id: tuya_far_detection + number_datapoint: 109 + min_value: 0 + max_value: 600 + step: 1 From 866ddb6e5729f11e2a3a107fa7145dc596b9a17a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 06:42:30 +0200 Subject: [PATCH 058/759] [core] Skip PlatformIO's private-package authorization probe (#18823) --- esphome/platformio/library.py | 6 ++- esphome/platformio/prefetch.py | 2 + esphome/platformio/runner.py | 14 ++++++- tests/unit_tests/test_platformio_library.py | 19 ++++++++++ tests/unit_tests/test_platformio_prefetch.py | 14 +++++++ tests/unit_tests/test_platformio_runner.py | 40 ++++++++++++++++++++ 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 3ff60f8aaab..fb6779b8078 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -616,11 +616,15 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.registry.client import RegistryClient class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: - self._registry_client = None self.pkg_type = "library" + self._registry_client = RegistryClient() + # The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages); + # instance-level so the ESPHome process never patches PlatformIO's class + self._registry_client.allowed_private_packages = lambda: False @staticmethod def is_system_compatible(value: Any, custom_system: Any = None) -> bool: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 17a06cb9c10..e648192b737 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -951,8 +951,10 @@ def main(argv: list[str]) -> int: """Subprocess entry point: ``prefetch ``.""" from esphome.core import CORE from esphome.log import setup_log + from esphome.platformio.runner import patch_registry_private_packages signal.signal(signal.SIGTERM, _sigterm) + patch_registry_private_packages() raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL") try: level = int(raw_level) if raw_level is not None else logging.INFO diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index 9bb2205a909..b9fbdec38d0 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -2,7 +2,8 @@ Invoked via ``python -m esphome.platformio.runner`` instead of ``python -m platformio`` so that the patches (incremental rebuild -preservation, download retries) apply inside the subprocess. Running +preservation, download retries, skipping the private-package probe) apply +inside the subprocess. Running PlatformIO in a subprocess keeps its ``sys.path`` mutations and other global state from leaking into the ESPHome process. """ @@ -105,6 +106,16 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def patch_registry_private_packages() -> None: + """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. + + ESPHome never uses private packages, so the answer is always False. + """ + from platformio.registry.client import RegistryClient + + RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign] + + _IGNORE_LIB_WARNINGS = "(?:Hash|Update)" # Regex patterns matched against each line of PlatformIO output. Lines that # match are dropped by RedirectText before they reach the parent process. @@ -152,6 +163,7 @@ FILTER_PLATFORMIO_LINES = [ def main() -> int: patch_structhash() patch_file_downloader() + patch_registry_private_packages() # Wrap stdout/stderr with RedirectText before PlatformIO runs: # diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 3bae39b3c1a..512c883c374 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -7,6 +7,7 @@ exercised in their own test modules).""" import json import logging from pathlib import Path +from unittest.mock import Mock import pytest @@ -228,6 +229,24 @@ def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): _resolve_registry_version("owner", "pkg", set()) +def test_make_registry_client_skips_private_package_probe(monkeypatch): + """Our client answers the probe locally without patching PlatformIO's class.""" + from platformio.account.client import AccountClient + from platformio.registry.client import RegistryClient + + pio_probe = RegistryClient.__dict__["allowed_private_packages"] + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + client = lib._make_registry_client().get_registry_client_instance() + + assert client.allowed_private_packages() is False + assert RegistryClient.__dict__["allowed_private_packages"] is pio_probe + + def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 77490fd8613..14c52dda8d5 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1225,6 +1225,20 @@ def test_main_runs_prefetch(tmp_path: Path) -> None: mock_prefetch.assert_called_once_with(tmp_path, "testenv") +def test_main_skips_private_package_probe_before_prefetch(tmp_path: Path) -> None: + """The registry probe patch is applied before any package manager runs.""" + order: list[str] = [] + with ( + patch.object(pf, "_prefetch", side_effect=lambda *_: order.append("prefetch")), + patch( + "esphome.platformio.runner.patch_registry_private_packages", + side_effect=lambda: order.append("patch"), + ), + ): + assert pf.main([str(tmp_path), "testenv"]) == 0 + assert order == ["patch", "prefetch"] + + def test_main_bad_argv_is_a_distinct_exit( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index f375aa457ac..007455f45a3 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -6,7 +6,9 @@ from collections.abc import Callable import io import sys from types import ModuleType +from unittest.mock import Mock +from platformio.registry.client import RegistryClient import pytest from esphome.platformio import runner @@ -30,6 +32,7 @@ def _prepare_main( monkeypatch.setattr(sys, "stderr", stream) monkeypatch.setattr(runner, "patch_structhash", lambda: None) monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + monkeypatch.setattr(runner, "patch_registry_private_packages", lambda: None) platformio = ModuleType("platformio") platformio_main = ModuleType("platformio.__main__") @@ -91,3 +94,40 @@ def test_main_still_filters_a_drained_partial_line( assert runner.main() == 0 assert buf.getvalue() == b"" + + +def test_main_applies_registry_private_packages_patch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The probe is patched before PlatformIO runs.""" + order: list[str] = [] + _prepare_main(monkeypatch, lambda: order.append("pio") or 0) + monkeypatch.setattr( + runner, "patch_registry_private_packages", lambda: order.append("patch") + ) + + assert runner.main() == 0 + assert order == ["patch", "pio"] + + +# Snapshot PlatformIO's own probe at import, before any test can patch it +_PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] + + +def test_patch_registry_private_packages_skips_account_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Answers False without touching the account client.""" + from platformio.account.client import AccountClient + + monkeypatch.setattr(RegistryClient, "allowed_private_packages", _PIO_PROBE) + monkeypatch.setattr( + AccountClient, + "get_account_info", + Mock(side_effect=AssertionError("account probe must not run")), + ) + + runner.patch_registry_private_packages() + + assert RegistryClient.allowed_private_packages() is False + assert RegistryClient().allowed_private_packages() is False From f8a4cfa945ef765e469daa03edbe263346a03154 Mon Sep 17 00:00:00 2001 From: Gytis Date: Tue, 8 Sep 2026 08:31:30 +0200 Subject: [PATCH 059/759] [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) --- esphome/components/lvgl/widgets/keyboard.py | 3 +- esphome/components/lvgl/widgets/qrcode.py | 3 +- esphome/components/lvgl/widgets/tabview.py | 3 +- .../lvgl/config/keyboard_no_label.yaml | 32 +++++++++++++++++ .../lvgl/config/qrcode_no_label.yaml | 34 ++++++++++++++++++ .../lvgl/config/tabview_no_label.yaml | 35 +++++++++++++++++++ .../lvgl/test_widget_label_dependency.py | 32 +++++++++++++++++ 7 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/config/keyboard_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/qrcode_no_label.yaml create mode 100644 tests/component_tests/lvgl/config/tabview_no_label.yaml create mode 100644 tests/component_tests/lvgl/test_widget_label_dependency.py diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index bcd2d2ae597..65516513a6c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -15,6 +15,7 @@ from ..defines import ( from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX +from .label import CONF_LABEL from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -49,7 +50,7 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL async def to_code(self, w: Widget, config: dict): add_lv_use("KEY_LISTENER") diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index df76ab6bb0d..59af9168aad 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -10,6 +10,7 @@ from ..types import lv_obj_t from . import Widget, WidgetType from .canvas import CONF_CANVAS from .img import CONF_IMAGE +from .label import CONF_LABEL CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -41,7 +42,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return CONF_CANVAS, CONF_IMAGE + return CONF_CANVAS, CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): await w.set_property( diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index ee252ecf0b2..77c88c48ff8 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec +from .label import CONF_LABEL from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -74,7 +75,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/tests/component_tests/lvgl/config/keyboard_no_label.yaml b/tests/component_tests/lvgl/config/keyboard_no_label.yaml new file mode 100644 index 00000000000..7a45a537d3d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_no_label.yaml @@ -0,0 +1,32 @@ +esphome: + name: test-keyboard-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - keyboard: + id: keyboard_widget diff --git a/tests/component_tests/lvgl/config/qrcode_no_label.yaml b/tests/component_tests/lvgl/config/qrcode_no_label.yaml new file mode 100644 index 00000000000..8bb1aafdd6e --- /dev/null +++ b/tests/component_tests/lvgl/config/qrcode_no_label.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-qrcode-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - qrcode: + id: qr_widget + size: 100 + text: "esphome.io" diff --git a/tests/component_tests/lvgl/config/tabview_no_label.yaml b/tests/component_tests/lvgl/config/tabview_no_label.yaml new file mode 100644 index 00000000000..a3c16ab3471 --- /dev/null +++ b/tests/component_tests/lvgl/config/tabview_no_label.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-tabview-no-label + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - tabview: + id: tabview_widget + tabs: + - name: "Tab 1" + id: tab_1 diff --git a/tests/component_tests/lvgl/test_widget_label_dependency.py b/tests/component_tests/lvgl/test_widget_label_dependency.py new file mode 100644 index 00000000000..9d3e24c8c5a --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_label_dependency.py @@ -0,0 +1,32 @@ +"""Widgets whose LVGL C implementation creates or references labels +internally (tab titles, key legends, the QR canvas fallback) must declare +the label dependency in ``get_uses()``. Otherwise a config that contains +no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and +fails at C compile time with undefined ``lv_label_*`` symbols. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df + + +@pytest.mark.parametrize( + "yaml_file", + [ + "qrcode_no_label.yaml", + "keyboard_no_label.yaml", + "tabview_no_label.yaml", + ], +) +def test_label_less_config_enables_lv_use_label( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, +) -> None: + generate_main(component_config_path(yaml_file)) + assert "LV_USE_LABEL" in df.get_defines() From c3ce07755f32292af3da6466aa1fc4a2cfeca07d Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Mon, 7 Sep 2026 23:36:42 -0700 Subject: [PATCH 060/759] [rf_bridge] Fix bucket sniffing with Portisch firmware (#17683) Co-authored-by: Bryan Li Co-authored-by: Claude Fable 5 --- esphome/components/rf_bridge/rf_bridge.cpp | 109 +++++++++++++++++---- esphome/components/rf_bridge/rf_bridge.h | 13 +++ 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 549cce72dfd..a4a4da5d8c0 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -18,6 +18,16 @@ void RFBridgeComponent::ack_() { } bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { + if (this->bucket_frame_candidate_ && byte == RF_CODE_START) { + // A queued next frame proves the trailing 0x55 really was the bucket + // frame's terminator: Portisch builds pulse entries from alternating + // signal edges, so the two level bits inside one pulse byte are always + // opposite — 0xAA (two high-level nibbles) cannot occur in pulse data. + // Finalize before this byte starts the new frame, so back-to-back + // deliveries are split even when loop() never observed a quiet gap + // between them. + this->finish_bucket_frame_(); + } size_t at = this->rx_buffer_.size(); this->rx_buffer_.push_back(byte); const uint8_t *raw = &this->rx_buffer_[0]; @@ -84,26 +94,21 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { break; } case RF_CODE_RFIN_BUCKET: { - if (byte != RF_CODE_STOP) { - return true; + if (at == 2) { + // The count byte: Portisch sends at most 7 buckets + sync, so 0 or + // >8 cannot be a genuine capture — reject before it can occupy the + // buffer for a full frame timeout. + return byte != 0 && byte <= B1_MAX_BUCKET_COUNT; } - - uint8_t buckets = raw[2] << 1; - std::string str; - char next_byte[3]; // 2 hex chars + null - - for (uint32_t i = 0; i <= at; i++) { - buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); - str += next_byte; - if ((i > 3) && buckets) { - buckets--; - } - if ((i < 3) || (buckets % 2) || (i == at - 1)) { - str += " "; - } - } - ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); - break; + // 0x55 is legal DATA inside a B1 frame: bucket durations are sent + // with only their HIGH byte masked to 7 bits, so a duration such as + // 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55 + // must therefore not end the capture. The header declares the table + // length (raw[2] pairs), so a 0x55 there is always data; one at or + // past the first pulse index is a terminator CANDIDATE, confirmed + // once the UART goes quiet (finish_bucket_frame_ in loop()). + this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast(raw[2]) * 2; + return true; } default: ESP_LOGW(TAG, "Unknown action: 0x%02X", action); @@ -119,6 +124,47 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return false; } +void RFBridgeComponent::finish_bucket_frame_() { + if (this->rx_buffer_.size() < 4) { + // The candidate flag requires a header + non-empty bucket table, so + // this cannot happen while flag and buffer stay consistent; guard the + // raw[2] / size-1 reads against any future divergence anyway. + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + return; + } + const uint8_t *raw = this->rx_buffer_.data(); + const size_t at = this->rx_buffer_.size() - 1; + + uint8_t buckets = raw[2] << 1; + std::string str; + char next_byte[3]; // 2 hex chars + null + + for (uint32_t i = 0; i <= at; i++) { + buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]); + str += next_byte; + if ((i > 3) && buckets) { + buckets--; + } + if ((i < 3) || (buckets % 2) || (i == at - 1)) { + str += " "; + } + } + ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str()); + + // Deliberately NOT ACKed: Portisch's B1 command handler leaves its + // last_sniffing_command at the previous mode (RF_CODE_RFIN), and its + // host-ACK handler re-arms sniffing from that stale value — so ACKing a + // bucket delivery silently reverts the radio to standard sniffing and + // ends bucket capture. Its delivery path is fire-and-forget and never + // waits for a host ACK. Stock Itead firmware never sends B1 frames, so + // suppressing this ACK cannot change stock-firmware behavior. + // https://github.com/esphome/esphome/issues/17682 + + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; +} + void RFBridgeComponent::write_byte_str_(const std::string &codes) { uint8_t code; int size = codes.length(); @@ -130,12 +176,31 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) { void RFBridgeComponent::loop() { const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_bridge_byte_ > 50) { + size_t avail = this->available(); + if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) { + // The trailing 0x55 was followed by UART quiet, so it really was the + // frame terminator and not an interior data byte. + this->finish_bucket_frame_(); + this->last_bridge_byte_ = now; + } + const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET; + if (receiving_bucket) { + // Never declare an in-progress bucket frame dead while its continuation + // bytes are already queued: a stalled loop() otherwise discards a live + // frame that the UART buffer proves is still arriving. + if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) { + ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)", + static_cast(this->rx_buffer_.size())); + this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; + this->last_bridge_byte_ = now; + } + } else if (now - this->last_bridge_byte_ > 50) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; this->last_bridge_byte_ = now; } - size_t avail = this->available(); while (avail > 0) { uint8_t buf[64]; size_t to_read = std::min(avail, sizeof(buf)); @@ -146,12 +211,14 @@ void RFBridgeComponent::loop() { for (size_t i = 0; i < to_read; i++) { if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } if (this->parse_bridge_byte_(buf[i])) { ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); this->last_bridge_byte_ = now; } else { this->rx_buffer_.clear(); + this->bucket_frame_candidate_ = false; } } } diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 5ad75650abb..cbb1880ec53 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -30,6 +30,17 @@ static const uint8_t RF_CODE_BEEP = 0xC0; static const uint8_t RF_CODE_STOP = 0x55; static const uint8_t RF_DEBOUNCE = 200; static const size_t MAX_RX_BUFFER_SIZE = 512; +// ~10 byte times at 19200 baud: long enough to prove the UART went quiet +// after a possible bucket-frame terminator, short enough to finish well +// before the next radio capture can be delivered. +static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5; +// Portisch drains a B1 frame's header, bucket table, and pulse data as +// separate UART writes, so an in-progress bucket frame tolerates a longer +// inter-region gap than the generic 50 ms inter-byte timeout. +static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250; +// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync +// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol. +static const uint8_t B1_MAX_BUCKET_COUNT = 8; struct RFBridgeData { uint16_t sync; @@ -67,10 +78,12 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { void ack_(); void decode_(); bool parse_bridge_byte_(uint8_t byte); + void finish_bucket_frame_(); void write_byte_str_(const std::string &codes); std::vector rx_buffer_; uint32_t last_bridge_byte_{0}; + bool bucket_frame_candidate_{false}; CallbackManager data_callback_; CallbackManager advanced_data_callback_; From 7660dd7fa7059a6154e65797c26e45d27aa8bf78 Mon Sep 17 00:00:00 2001 From: raykholo Date: Tue, 8 Sep 2026 02:57:33 -0400 Subject: [PATCH 061/759] [anova] Re-assert temperature unit on every poll cycle (#17141) --- esphome/components/anova/anova.cpp | 107 ++++++++++++++--------------- esphome/components/anova/anova.h | 13 +++- 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 6e382872e2b..b0769bb622e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } void Anova::setup() { this->codec_ = make_unique(); - this->current_request_ = 0; + this->poll_step_ = PollStep::IDLE; } void Anova::loop() { @@ -22,6 +22,15 @@ void Anova::loop() { this->disable_loop(); } +void Anova::write_request_(AnovaPacket *pkt) { + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, + pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } +} + void Anova::control(const ClimateCall &call) { auto mode_val = call.get_mode(); if (mode_val.has_value()) { @@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) { ESP_LOGW(TAG, "Unsupported mode: %d", mode); return; } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(pkt); } auto target_temp = call.get_target_temperature(); if (target_temp.has_value()) { - auto *pkt = this->codec_->get_set_target_temp_request(*target_temp); - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(this->codec_->get_set_target_temp_request(*target_temp)); } } @@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_DISCONNECT_EVT: { this->current_temperature = NAN; this->target_temperature = NAN; + this->poll_step_ = PollStep::IDLE; this->publish_state(); break; } @@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; - this->current_request_ = 0; - this->update(); + this->poll_step_ = PollStep::IDLE; + this->update(); // begin the first poll cycle immediately break; } case ESP_GATTC_NOTIFY_EVT: { @@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; } if (this->codec_->has_unit()) { - this->fahrenheit_ = (this->codec_->unit_ == 'f'); - ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius"); - this->current_request_++; + ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius"); } this->publish_state(); - if (this->current_request_ > 1) { - AnovaPacket *pkt = nullptr; - switch (this->current_request_++) { - case 2: - pkt = this->codec_->get_read_target_temp_request(); - break; - case 3: - pkt = this->codec_->get_read_current_temp_request(); - break; - default: - this->current_request_ = 1; - break; - } - if (pkt != nullptr) { - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - } + // Advance the poll cycle to its next request based on the reply we got. + switch (this->poll_step_) { + case PollStep::SET_UNIT: + this->poll_step_ = PollStep::STATUS; + this->write_request_(this->codec_->get_read_device_status_request()); + break; + case PollStep::STATUS: + this->poll_step_ = PollStep::TARGET; + this->write_request_(this->codec_->get_read_target_temp_request()); + break; + case PollStep::TARGET: + this->poll_step_ = PollStep::CURRENT; + this->write_request_(this->codec_->get_read_current_temp_request()); + break; + case PollStep::CURRENT: + this->poll_step_ = PollStep::IDLE; // full cycle complete + break; + default: + // A reply to an ad-hoc control() write, outside a managed cycle. + break; } break; } @@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } } -void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); } +void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); } void Anova::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) return; - - if (this->current_request_ < 2) { - AnovaPacket *pkt; - if (this->current_request_ == 0) { - pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); - } else { - pkt = this->codec_->get_read_device_status_request(); - } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - this->current_request_++; + if (this->poll_step_ != PollStep::IDLE) { + // The previous cycle never finished within a full polling interval -- a + // reply was missed or a write failed. Restart the cycle rather than stall; + // the polling interval itself acts as the timeout. A late reply from the + // abandoned cycle is harmless: state decoding happens on every notify + // regardless of step, and each notify sends at most one follow-up request. + ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(), + static_cast(this->poll_step_)); } + // Re-assert the configured unit at the start of every poll cycle, then fall + // through the status/temperature reads via the notification handler. Always + // command the configured unit (want_fahrenheit_) -- never the last value the + // device reported, or a drift to 'c' would lock itself in. + this->poll_step_ = PollStep::SET_UNIT; + this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c')); } } // namespace esphome::anova diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 49b1100c372..a0fa03df016 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie void set_unit_of_measurement(const char *unit); protected: + // A poll cycle re-asserts the configured unit, then reads device state. + // Re-asserting every cycle prevents the cooker from silently reverting to + // its default (Celsius); previously the unit was only set once on + // connection, so a drift persisted (and corrupted the F/C interpretation of + // subsequent readings) until the BLE link was re-established. + enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE }; + + void write_request_(AnovaPacket *pkt); + std::unique_ptr codec_; void control(const climate::ClimateCall &call) override; uint16_t char_handle_; - uint8_t current_request_; - bool fahrenheit_; + bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies + PollStep poll_step_{PollStep::IDLE}; }; } // namespace esphome::anova From f8b2e53609051bf6ac9a626305a7e6304c67393f Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:02:49 -0400 Subject: [PATCH 062/759] [atm90e32] Verify offset calibration writes (#18701) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/atm90e32/atm90e32.cpp | 360 ++++++++++-------- esphome/components/atm90e32/atm90e32.h | 71 ++-- tests/components/atm90e32/__init__.py | 5 + .../offset_register_verification_test.cpp | 62 +++ 4 files changed, 322 insertions(+), 176 deletions(-) create mode 100644 tests/components/atm90e32/__init__.py create mode 100644 tests/components/atm90e32/offset_register_verification_test.cpp diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index d948b3741df..23701e78348 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -9,6 +9,10 @@ namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; +static const LogString *offset_calibration_name(bool power_offsets) { + return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); +} + static uint32_t pref_hash(const char *prefix, const char *name_space) { auto hash = fnv1_hash(prefix); return fnv1_hash_extend(hash, name_space); @@ -203,13 +207,12 @@ void ATM90E32Component::setup() { // Initialize flash storage for power offset calibrations uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); - this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); bool migrated_power_offset = false; if (has_distinct_legacy_namespace) { uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); - auto legacy_power_offset_pref = - global_preferences->make_preference(legacy_po_hash, true); - PowerOffsetCalibration power_offset_data[3]{}; + auto legacy_power_offset_pref = global_preferences->make_preference(legacy_po_hash, true); + OffsetCalibration power_offset_data[3]{}; int migration_status = migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); migrated_power_offset = migration_status > 0; @@ -224,20 +227,20 @@ void ATM90E32Component::setup() { global_preferences->sync(); } - this->restore_offset_calibrations_(); - this->restore_power_offset_calibrations_(); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], - static_cast(this->offset_phase_[phase].voltage_offset_)); + static_cast(this->offset_phase_[phase].first_offset)); this->write16_(this->current_offset_registers[phase], - static_cast(this->offset_phase_[phase].current_offset_)); + static_cast(this->offset_phase_[phase].second_offset)); this->write16_(this->power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].active_power_offset)); + static_cast(this->power_offset_phase_[phase].first_offset)); this->write16_(this->reactive_power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].reactive_power_offset)); + static_cast(this->power_offset_phase_[phase].second_offset)); } } @@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, - this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset, + this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].active_power_offset, - this->config_power_offset_phase_[phase].reactive_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset, + this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); } @@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); } @@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } } -void ATM90E32Component::save_offset_calibration_to_memory_() { +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); - bool success = this->offset_pref_.save(&this->offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_offset_calibration_ = true; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); - } -} + const LogString *name = offset_calibration_name(power_offsets); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; -void ATM90E32Component::save_power_offset_calibration_to_memory_() { - const char *cs = this->get_calibration_id_(); - bool success = this->power_offset_pref_.save(&this->power_offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_power_offset_calibration_ = true; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); + const bool writes_verified = this->verify_offset_writes_(type); + bool saved = false; + bool synced = false; + if (writes_verified) { + saved = preference->save(offsets); + synced = global_preferences->sync(); } + + if (writes_verified && saved && synced) { + this->using_saved_calibrations_ = true; + *has_stored = true; + *restored = true; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, + LOG_STR_ARG(name), LOG_STR_ARG(name)); + return; + } + + if (writes_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + } + const bool rollback_verified = this->verify_offset_writes_(type); + + bool rollback_persisted = false; + if (writes_verified) { + OffsetCalibration rollback[3]{}; + prepare_offset_rollback(previous, previous_restored, rollback); + const bool rollback_saved = preference->save(&rollback); + const bool rollback_synced = global_preferences->sync(); + rollback_persisted = rollback_saved && rollback_synced; + if (!rollback_saved || !rollback_synced) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + } + } + + *restored = previous_restored; + if (rollback_persisted) + *has_stored = previous_restored; + this->using_saved_calibrations_ = previous_using_saved; + if (!rollback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, + LOG_STR_ARG(name)); + return; + } + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } void ATM90E32Component::run_offset_calibrations() { @@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; + const bool previous_restored = this->restored_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); @@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->save_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], + this->power_offset_phase_[2]}; + const bool previous_restored = this->restored_power_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_power_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } -void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) { - // Save to runtime - this->offset_phase_[phase].voltage_offset_ = voltage_offset; - this->phase_[phase].voltage_offset_ = voltage_offset; +void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase]; + offsets.first_offset = first_offset; + offsets.second_offset = second_offset; + if (power_offsets) { + this->phase_[phase].active_power_offset_ = first_offset; + this->phase_[phase].reactive_power_offset_ = second_offset; + } else { + this->phase_[phase].voltage_offset_ = first_offset; + this->phase_[phase].current_offset_ = second_offset; + } - // Save to flash-storable struct - this->offset_phase_[phase].current_offset_ = current_offset; - this->phase_[phase].current_offset_ = current_offset; - - // Write to registers + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(voltage_offset_registers[phase], static_cast(voltage_offset)); - this->write16_(current_offset_registers[phase], static_cast(current_offset)); - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); -} - -void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) { - // Save to runtime - this->phase_[phase].active_power_offset_ = p_offset; - this->phase_[phase].reactive_power_offset_ = q_offset; - - // Save to flash-storable struct - this->power_offset_phase_[phase].active_power_offset = p_offset; - this->power_offset_phase_[phase].reactive_power_offset = q_offset; - - // Write to registers - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(this->power_offset_registers[phase], static_cast(p_offset)); - this->write16_(this->reactive_power_offset_registers[phase], static_cast(q_offset)); + this->write16_(first_registers[phase], static_cast(first_offset)); + this->write16_(second_registers[phase], static_cast(second_offset)); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } @@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() { ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } -void ATM90E32Component::restore_offset_calibrations_() { +void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); + const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset"); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + OffsetCalibration(*config_offsets)[3] = + power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; + const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_; + const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_; + for (uint8_t i = 0; i < 3; ++i) - this->config_offset_phase_[i] = this->offset_phase_[i]; - - bool have_data = this->offset_pref_.load(&this->offset_phase_); + (*config_offsets)[i] = (*offsets)[i]; + const bool have_data = preference->load(offsets); bool all_zero = true; if (have_data) { - for (auto &phase : this->offset_phase_) { - if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + for (const auto &phase : *offsets) { + if (phase.first_offset != 0 || phase.second_offset != 0) { all_zero = false; break; } } } - if (have_data && !all_zero) { - this->restored_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; phase++) { - auto &offset = this->offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_voltage_offset_[phase] && - offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) - mismatch = true; - if (this->has_config_current_offset_[phase] && - offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) - mismatch = true; - if (mismatch) - this->offset_calibration_mismatch_[phase] = true; + *has_stored = have_data && !all_zero; + *restored = false; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = false; + if (*has_stored) { + mismatches[phase] = + (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); } - } else { + } + + if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) - this->offset_phase_[phase] = this->config_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + (*offsets)[phase] = (*config_offsets)[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name)); } for (uint8_t phase = 0; phase < 3; phase++) { - write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, - this->offset_phase_[phase].current_offset_); + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } -} - -void ATM90E32Component::restore_power_offset_calibrations_() { - const char *cs = this->get_calibration_id_(); - for (uint8_t i = 0; i < 3; ++i) - this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; - - bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); - - bool all_zero = true; - if (have_data) { - for (auto &phase : this->power_offset_phase_) { - if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { - all_zero = false; - break; - } - } + const bool initial_values_verified = this->verify_offset_writes_(type); + if (initial_values_verified) { + const auto state = resolve_offset_restore_state(*has_stored, true, false); + *restored = state.restored; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); + return; } - if (have_data && !all_zero) { - this->restored_power_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; ++phase) { - auto &offset = this->power_offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_active_power_offset_[phase] && - offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) - mismatch = true; - if (this->has_config_reactive_power_offset_[phase] && - offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) - mismatch = true; - if (mismatch) - this->power_offset_calibration_mismatch_[phase] = true; - } + this->using_saved_calibrations_ = false; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + for (uint8_t phase = 0; phase < 3; phase++) { + (*offsets)[phase] = (*config_offsets)[phase]; + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); + } + const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); + *restored = state.restored; + if (state.values_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_ARG(name)); } else { - for (uint8_t phase = 0; phase < 3; ++phase) - this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); - } - - for (uint8_t phase = 0; phase < 3; ++phase) { - write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_ARG(name)); } } @@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() { void ATM90E32Component::clear_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_offset_calibration_) { + if (!this->has_stored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); return; @@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = - this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0; int16_t current_offset = - this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } @@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() { this->offset_pref_.save(&zero_offsets); // Clear stored values in flash global_preferences->sync(); + this->has_stored_offset_calibration_ = false; this->restored_offset_calibration_ = false; for (bool &phase : this->offset_calibration_mismatch_) phase = false; @@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() { void ATM90E32Component::clear_power_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_power_offset_calibration_) { + if (!this->has_stored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); return; @@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t active_offset = - this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; - int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] - ? this->config_power_offset_phase_[phase].reactive_power_offset - : 0; - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0; + int16_t reactive_offset = + this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; this->power_offset_pref_.save(&zero_power_offsets); global_preferences->sync(); + this->has_stored_power_offset_calibration_ = false; this->restored_power_offset_calibration_ = false; for (bool &phase : this->power_offset_calibration_mismatch_) phase = false; @@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() { return success; // Return true if all writes were successful, false otherwise } +bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const char *cs = this->get_calibration_id_(); + const LogString *name = offset_calibration_name(power_offsets); + const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage"); + const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current"); + const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_; + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; + bool success = true; + for (uint8_t phase = 0; phase < 3; phase++) { + const uint16_t first = this->read16_(first_registers[phase]); + const uint16_t second = this->read16_(second_registers[phase]); + if (!offset_register_value_matches(first, offsets[phase].first_offset) || + !offset_register_value_matches(second, offsets[phase].second_offset)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name), + phase_labels[phase], LOG_STR_ARG(first_name), static_cast(first), offsets[phase].first_offset, + LOG_STR_ARG(second_name), static_cast(second), offsets[phase].second_offset); + success = false; + } + } + return success; +} + #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c636e5065a5..fe7d903962f 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,6 +13,40 @@ namespace esphome::atm90e32 { +inline bool offset_register_value_matches(uint16_t actual, int16_t expected) { + return actual == static_cast(expected); +} + +struct OffsetCalibration { + int16_t first_offset{0}; + int16_t second_offset{0}; +}; + +static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); + +enum class OffsetCalibrationType : uint8_t { + OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + OFFSET_CALIBRATION_TYPE_POWER, +}; + +struct OffsetRestoreState { + bool restored; + bool values_verified; +}; + +inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, + bool fallback_values_verified) { + if (initial_values_verified) + return {has_stored_values, true}; + return {false, fallback_values_verified}; +} + +inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, + OffsetCalibration (&rollback)[3]) { + for (uint8_t phase = 0; phase < 3; phase++) + rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; +} + class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent, this->has_config_current_gain_[phase] = true; } void set_voltage_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].voltage_offset_ = offset; + this->offset_phase_[phase].first_offset = offset; this->has_config_voltage_offset_[phase] = true; } void set_current_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].current_offset_ = offset; + this->offset_phase_[phase].second_offset = offset; this->has_config_current_offset_[phase] = true; } void set_active_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].active_power_offset = offset; + this->power_offset_phase_[phase].first_offset = offset; this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].reactive_power_offset = offset; + this->power_offset_phase_[phase].second_offset = offset; this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } @@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent, float get_chip_temperature_(); bool get_publish_interval_flag_() { return publish_interval_flag_; }; void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; - void restore_offset_calibrations_(); - void restore_power_offset_calibrations_(); + void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); - void save_power_offset_calibration_to_memory_(); - void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); - void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type); + void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type); void write_gains_to_registers_(); bool verify_gain_writes_(); + bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); const char *get_calibration_id_(); @@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent, uint32_t cumulative_reverse_active_energy_{0}; } phase_[3]; - struct OffsetCalibration { - int16_t voltage_offset_{0}; - int16_t current_offset_{0}; - } offset_phase_[3]; - + OffsetCalibration offset_phase_[3]; OffsetCalibration config_offset_phase_[3]; - - struct PowerOffsetCalibration { - int16_t active_power_offset{0}; - int16_t reactive_power_offset{0}; - } power_offset_phase_[3]; - - PowerOffsetCalibration config_power_offset_phase_[3]; + OffsetCalibration power_offset_phase_[3]; + OffsetCalibration config_power_offset_phase_[3]; struct GainCalibration { uint16_t voltage_gain{1}; @@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent, bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; const char *instance_id_{nullptr}; + bool has_stored_offset_calibration_{false}; + bool has_stored_power_offset_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/tests/components/atm90e32/__init__.py b/tests/components/atm90e32/__init__.py new file mode 100644 index 00000000000..37d6797e2dd --- /dev/null +++ b/tests/components/atm90e32/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["sensor", "spi"] diff --git a/tests/components/atm90e32/offset_register_verification_test.cpp b/tests/components/atm90e32/offset_register_verification_test.cpp new file mode 100644 index 00000000000..3bb3eb76ea9 --- /dev/null +++ b/tests/components/atm90e32/offset_register_verification_test.cpp @@ -0,0 +1,62 @@ +#include + +#include "esphome/components/atm90e32/atm90e32.h" + +namespace esphome::atm90e32::testing { + +TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { + EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); + EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); +} + +TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { + EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); + EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) { + const auto state = resolve_offset_restore_state(true, true, false); + + EXPECT_TRUE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) { + const auto state = resolve_offset_restore_state(true, false, true); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) { + const auto state = resolve_offset_restore_state(true, false, false); + + EXPECT_FALSE(state.restored); + EXPECT_FALSE(state.values_verified); +} + +TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) { + const auto state = resolve_offset_restore_state(false, true, false); + + EXPECT_FALSE(state.restored); + EXPECT_TRUE(state.values_verified); +} + +TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) { + const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}}; + OffsetCalibration rollback[3]{}; + + prepare_offset_rollback(previous, true, rollback); + for (uint8_t phase = 0; phase < 3; phase++) { + EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset); + EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset); + } + + prepare_offset_rollback(previous, false, rollback); + for (const auto &phase : rollback) { + EXPECT_EQ(phase.first_offset, 0); + EXPECT_EQ(phase.second_offset, 0); + } +} + +} // namespace esphome::atm90e32::testing From f89b9e704c7dfabce1e8ce670dd2c6e7aa9ea086 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:48 +0200 Subject: [PATCH 063/759] Bump bundled esphome-device-builder to 1.14.5 (#19040) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index da76ab7b6a7..ac84ee4689f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 RUN \ platformio settings set enable_telemetry No \ From 9b6facb20d5461dfaf47fd3993a7b4601f082c34 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 15:46:17 -0400 Subject: [PATCH 064/759] [core] Fix use-after-free when deleting a running StaticTask (#19048) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../micro_wake_word/micro_wake_word.cpp | 4 +-- .../mixer/speaker/mixer_speaker.cpp | 4 +-- .../resampler/speaker/resampler_speaker.cpp | 4 +-- .../speaker/media_player/audio_pipeline.cpp | 11 +++++-- esphome/core/static_task.cpp | 30 ++++++++++++++----- esphome/core/static_task.h | 17 +++++++---- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 3dadb78077d..cebfe8e7914 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -446,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..0b79010773a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -382,8 +382,8 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); this->all_stopped_since_ms_ = 0; diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index f1ebd180cc0..edda00ae061 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 010f0c50b33..c286a9d7d66 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() { if (!this->is_playing_) { // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks if (this->read_task_.is_created() || this->decode_task_.is_created()) { - this->read_task_.deallocate(); - this->decode_task_.deallocate(); + // Both are attempted every time; a task that is still running on the other core is freed by a + // subsequent call, and freeing an already freed task succeeds without doing anything + bool read_task_freed = this->read_task_.deallocate(); + bool decode_task_freed = this->decode_task_.deallocate(); + if (!read_task_freed || !decode_task_freed) { + // A task is still running on the other core, so keep the pipeline in its current state and try + // again on the next call + return AudioPipelineState::PLAYING; + } if (this->hard_stop_) { // Stop command was sent, so immediately end the playback this->speaker_->stop(); diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 4cfead44c29..43011083159 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return true; } -void StaticTask::destroy() { - if (this->handle_ != nullptr) { - TaskHandle_t handle = this->handle_; - this->handle_ = nullptr; - vTaskDelete(handle); +bool StaticTask::destroy() { + if (this->handle_ == nullptr) { + return true; } + + // Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks + // the other core to yield though, so the task may still be running on it for a moment. + vTaskSuspend(this->handle_); + if (eTaskGetState(this->handle_) != eSuspended) { + // The task is still running on the other core and using its stack. Deleting it now would only put it on + // the termination list and return, so the caller has to try again once it has been swapped out. + return false; + } + + // The task cannot run again, so the delete completes right away instead of being left to the idle task. + TaskHandle_t handle = this->handle_; + this->handle_ = nullptr; + vTaskDelete(handle); + return true; } -void StaticTask::deallocate() { - this->destroy(); +bool StaticTask::deallocate() { + if (!this->destroy()) { + return false; + } if (this->stack_buffer_ != nullptr) { RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); @@ -57,6 +72,7 @@ void StaticTask::deallocate() { this->stack_buffer_ = nullptr; this->stack_size_ = 0; } + return true; } } // namespace esphome diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 5fd5b38f9ef..e2996abedae 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -11,6 +11,7 @@ namespace esphome { /** Helper for FreeRTOS static task management. * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: @@ -23,7 +24,7 @@ class StaticTask { /// @brief Allocate stack and create task. /// @param fn Task function /// @param name Task name (for debug) - /// @param stack_size Stack size in StackType_t words + /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM @@ -31,11 +32,17 @@ class StaticTask { bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call. - void destroy(); + /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is + /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. + /// @return true if the task was deleted; false if it is still running on another core, in which case the + /// caller should try again later. + bool destroy(); - /// @brief Delete the task (if running) and free the stack buffer. - void deallocate(); + /// @brief Delete the task (if created) and free the stack buffer. + /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// which case the caller should try again later. + bool deallocate(); protected: TaskHandle_t handle_{nullptr}; From 0a1e2acbcba4521391742824c617c8cf206beb63 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:04:34 -0400 Subject: [PATCH 065/759] [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) --- esphome/components/audio/audio_reader.cpp | 3 +++ esphome/components/audio/audio_transfer_buffer.cpp | 12 ++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../components/micro_wake_word/micro_wake_word.cpp | 2 +- esphome/components/microphone/microphone_source.h | 2 +- esphome/components/mixer/speaker/mixer_speaker.cpp | 12 ++++++------ .../resampler/speaker/resampler_speaker.cpp | 6 +++--- .../speaker/media_player/audio_pipeline.cpp | 12 +++++++----- 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1c2eb129046..b78a151ee42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -218,8 +218,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cebfe8e7914..cf239be6960 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -129,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // to drain it - reset() is a consumer operation and must run on the inference task's thread. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 0b79010773a..ef21da65c5a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index edda00ae061..16d2d5dc9e2 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); } else { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); } else { @@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const { bool has_ring_buffer_data = false; if (this->requires_resampling_()) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer) { + if (temp_ring_buffer != nullptr) { has_ring_buffer_data = (temp_ring_buffer->available() > 0); } } @@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { this_resampler->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index c286a9d7d66..509984cfa29 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -322,17 +322,17 @@ void AudioPipeline::read_task(void *params) { if (err == ESP_OK) { size_t file_ring_buffer_size = this_pipeline->buffer_size_; - std::shared_ptr temp_ring_buffer; + std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; } - if (!this_pipeline->raw_file_ring_buffer_.use_count()) { + if (temp_ring_buffer == nullptr) { err = ESP_ERR_NO_MEM; } else { - reader->add_sink(this_pipeline->raw_file_ring_buffer_); + err = reader->add_sink(temp_ring_buffer); } } @@ -403,7 +403,9 @@ void AudioPipeline::decode_task(void *params) { make_unique(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); - decoder->add_source(this_pipeline->raw_file_ring_buffer_); + if (err == ESP_OK) { + err = decoder->add_source(this_pipeline->raw_file_ring_buffer_); + } if (err != ESP_OK) { // Send specific error message From ac79173f4ae83ff10c6a571140091be6ec7878b1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:05:02 -0400 Subject: [PATCH 066/759] [i2s_audio] Fix spurious driver failure (#19045) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index b78a151ee42..1382a870465 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough From 9c16aba6f78af2657cd9e5876d0e771c72692fcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:07:06 +0200 Subject: [PATCH 067/759] [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 4de706120e1..d17ebf235e5 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.24") + cg.add_library("esphome/noise-c", "0.1.26") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.6") + cg.add_library("esphome/libsodium", "1.10021.8") # 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 779a05e7de4..738773d1b56 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.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.24 ; noise (api, ota) + esphome/noise-c@0.1.26 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.24 ; used by noise (api, ota) + esphome/noise-c@0.1.26 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 4f7f5a4a4c8..00f22ca1389 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.24\n" + " esphome/noise-c @ 0.1.26\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.24"] + assert libs == ["esphome/noise-c @ 0.1.26"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.24", - "esphome/noise-c @ 0.1.24", + "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 0.1.26", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.24"] + assert cls.calls == ["esphome/noise-c @ 0.1.26"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.24"] is None + assert compats["esphome/noise-c @ 0.1.26"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) cls.deps = { - "esphome/noise-c @ 0.1.24": [ + "esphome/noise-c @ 0.1.26": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"]) + mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 14c52dda8d5..b03bff19a27 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 42fffd16fef3d08f58b5ace59b0545e551c07e64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Sep 2026 23:08:16 +0200 Subject: [PATCH 068/759] [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) --- esphome/components/esphome/ota/ota_esphome.cpp | 5 ++++- esphome/espota2.py | 9 ++++++--- tests/unit_tests/test_espota2.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1005ed214b6..f853ed6a2db 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/espota2.py b/esphome/espota2.py index ce403c398db..c683ffa323c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 # across the addresses on top of that. EXTRA_UPLOAD_ATTEMPTS = 2 UPLOAD_RETRY_DELAY = 5.0 +# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA +# (105 s) so a stalled session is gone before a retry, and long enough for lwIP +# to get a lost chunk ack through after the retransmit run seen in practice +DATA_PHASE_TIMEOUT = 160.0 _LOGGER = logging.getLogger(__name__) @@ -694,8 +698,7 @@ def perform_ota( _LOGGER.info("Handshake complete") - # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures - sock.settimeout(90.0) + sock.settimeout(DATA_PHASE_TIMEOUT) if extended_proto: send_check(sock, ota_type, "ota type") @@ -854,7 +857,7 @@ def run_ota_impl_( # 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 + # 105s 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 = "" diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 8867e2c215b..2d65e8e0798 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,9 @@ def test_perform_ota_no_auth( "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" in caplog.text ) + # The data phase timeout must outlast the device's 105 s data timeout + mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT) + assert espota2.DATA_PHASE_TIMEOUT > 105.0 @pytest.mark.usefixtures("mock_time") From d2bc056f0ab3ea93e62c7ca468f7a3da17a3f421 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 8 Sep 2026 17:31:18 -0400 Subject: [PATCH 069/759] [sendspin] Add codec preference list to the media source (#19047) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sendspin/__init__.py | 26 ++++-- .../sendspin/media_source/__init__.py | 31 +++++++ .../sendspin/test_media_source.py | 90 +++++++++++++++++++ .../sendspin/common-media_source.yaml | 1 + 4 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tests/component_tests/sendspin/test_media_source.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3faddd..8ef11a7f909 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) - # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate - # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - # OPUS only supports 48 kHz audio - codecs = [CODEC_FORMAT_FLAC] - if sample_rate == 48000: - codecs.append(CODEC_FORMAT_OPUS) - codecs.append(CODEC_FORMAT_PCM) + codecs = player_cfg[CONF_CODECS] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f0..6a9f1f18ba2 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_( ) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config + + def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 00000000000..6c2f79198dd --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..0c136fbd43d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,4 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + codecs: [pcm, opus, flac] From 008677298ada0a95adeef14c5ac88d1895d5fb2f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:54:44 +1200 Subject: [PATCH 070/759] Bump version to 2026.9.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 060de51d3af..97ce92240c7 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b2 +PROJECT_NUMBER = 2026.9.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 287804ace3b..b013098f336 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b2" +__version__ = "2026.9.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From fd598057efdfa689a10b53329753a936a15016a1 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 9 Sep 2026 14:22:23 +0200 Subject: [PATCH 071/759] [sendspin] Fix codec enum codegen when codecs is not set (#19055) --- esphome/components/sendspin/__init__.py | 2 +- tests/components/sendspin/common-media_source.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 8ef11a7f909..c1970ab1325 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -307,7 +307,7 @@ async def to_code(config: ConfigType) -> None: player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - codecs = player_cfg[CONF_CODECS] + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 0c136fbd43d..1977b79c04d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,4 +9,3 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal - codecs: [pcm, opus, flac] From 58ca3456845ca130ac106796225e8ea4cb9c5107 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:42:09 -0400 Subject: [PATCH 072/759] [ci] Refresh integration test durations (#19049) --- .../integration_test_durations.json | 293 +++++++++--------- 1 file changed, 152 insertions(+), 141 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 5a5aac3b229..b4a7f4e1aeb 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,143 +1,154 @@ { - "tests/integration/test_action_concurrent_reentry.py": 57.91, - "tests/integration/test_addressable_light_transition.py": 21.25, - "tests/integration/test_alarm_control_panel_state_transitions.py": 70.71, - "tests/integration/test_api_action_metadata.py": 66.6, - "tests/integration/test_api_action_responses.py": 36.1, - "tests/integration/test_api_action_timeout.py": 68.86, - "tests/integration/test_api_conditional_memory.py": 15.48, - "tests/integration/test_api_custom_services.py": 18.77, - "tests/integration/test_api_get_time_response_timezone.py": 21.08, - "tests/integration/test_api_homeassistant.py": 65.59, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05, - "tests/integration/test_api_list_entities_backpressure.py": 13.88, - "tests/integration/test_api_message_size_batching.py": 29.98, - "tests/integration/test_api_reboot_timeout.py": 16.05, - "tests/integration/test_api_string_lambda.py": 15.31, - "tests/integration/test_api_vv_logging.py": 19.28, - "tests/integration/test_api_zero_psk_provisioning.py": 31.5, - "tests/integration/test_areas_and_devices.py": 24.95, - "tests/integration/test_automation_wait_actions.py": 20.92, - "tests/integration/test_automations.py": 35.19, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39, - "tests/integration/test_binary_sensor_invalidate_state.py": 18.41, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69, - "tests/integration/test_build_info.py": 18.7, - "tests/integration/test_camera_mock.py": 16.23, - "tests/integration/test_climate_control_action.py": 21.14, - "tests/integration/test_climate_custom_modes.py": 20.74, - "tests/integration/test_continuation_actions.py": 16.81, - "tests/integration/test_cover_control_action.py": 20.34, - "tests/integration/test_crc8_helper.py": 9.36, - "tests/integration/test_device_id_in_state.py": 44.67, - "tests/integration/test_duplicate_entities.py": 23.58, - "tests/integration/test_entity_icon.py": 34.35, - "tests/integration/test_fan_turn_on_action.py": 24.23, - "tests/integration/test_fnv1_hash_object_id.py": 16.21, - "tests/integration/test_fnv1a_hash.py": 13.38, - "tests/integration/test_gpio_expander_cache.py": 13.06, - "tests/integration/test_host_logger_thread_safety.py": 23.66, - "tests/integration/test_host_mode_basic.py": 8.01, - "tests/integration/test_host_mode_batch_delay.py": 21.0, - "tests/integration/test_host_mode_climate_basic_state.py": 22.14, - "tests/integration/test_host_mode_climate_control.py": 19.39, - "tests/integration/test_host_mode_empty_string_options.py": 21.76, - "tests/integration/test_host_mode_entity_fields.py": 29.61, - "tests/integration/test_host_mode_fan_preset.py": 20.01, - "tests/integration/test_host_mode_many_entities.py": 39.08, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92, - "tests/integration/test_host_mode_noise_encryption.py": 42.42, - "tests/integration/test_host_mode_reconnect.py": 3.41, - "tests/integration/test_host_mode_sensor.py": 22.96, - "tests/integration/test_host_ota.py": 29.5, - "tests/integration/test_host_preferences.py": 16.06, - "tests/integration/test_host_preferences_suspend_resume.py": 18.71, - "tests/integration/test_improv_serial_uart.py": 20.22, - "tests/integration/test_large_message_batching.py": 26.56, - "tests/integration/test_legacy_area.py": 22.72, - "tests/integration/test_legacy_climate_compat.py": 14.13, - "tests/integration/test_legacy_fan_compat.py": 14.33, - "tests/integration/test_light_automations.py": 18.81, - "tests/integration/test_light_binary_effect_off_phase.py": 8.38, - "tests/integration/test_light_calls.py": 21.88, - "tests/integration/test_light_constant_brightness.py": 59.45, - "tests/integration/test_light_control_action.py": 31.91, - "tests/integration/test_light_dim_relative_action.py": 14.43, - "tests/integration/test_light_effect_zero_brightness.py": 25.05, - "tests/integration/test_light_initial_state.py": 18.97, - "tests/integration/test_light_toggle_action.py": 17.44, - "tests/integration/test_lock_automations.py": 18.9, - "tests/integration/test_logger_buffered_recursion_guard.py": 18.2, - "tests/integration/test_loop_disable_enable.py": 63.35, - "tests/integration/test_loop_interval_decoupling.py": 17.7, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56, - "tests/integration/test_micros_to_millis.py": 15.89, - "tests/integration/test_multi_click_trigger.py": 17.23, - "tests/integration/test_multi_device_preferences.py": 19.4, - "tests/integration/test_noise_encryption_key_protection.py": 72.59, - "tests/integration/test_object_id_api_verification.py": 19.22, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77, - "tests/integration/test_object_id_no_friendly_name.py": 45.8, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4, - "tests/integration/test_online_image_bmp.py": 37.24, - "tests/integration/test_oversized_payloads.py": 55.75, - "tests/integration/test_preference_key_stability.py": 25.49, - "tests/integration/test_runtime_stats.py": 29.81, - "tests/integration/test_safe_mode_loop_runs.py": 6.26, - "tests/integration/test_scheduler_blocking_warning.py": 37.98, - "tests/integration/test_scheduler_bulk_cleanup.py": 18.67, - "tests/integration/test_scheduler_defer_cancel.py": 18.46, - "tests/integration/test_scheduler_defer_cancel_regular.py": 16.34, - "tests/integration/test_scheduler_defer_fifo_simple.py": 18.26, - "tests/integration/test_scheduler_defer_stress.py": 17.74, - "tests/integration/test_scheduler_heap_stress.py": 3.89, - "tests/integration/test_scheduler_internal_id_no_collision.py": 20.01, - "tests/integration/test_scheduler_interval_reschedule.py": 16.29, - "tests/integration/test_scheduler_interval_zero_coerced.py": 16.09, - "tests/integration/test_scheduler_null_name.py": 14.69, - "tests/integration/test_scheduler_numeric_id_test.py": 17.08, - "tests/integration/test_scheduler_pool.py": 19.88, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.42, - "tests/integration/test_scheduler_recursive_timeout.py": 4.3, - "tests/integration/test_scheduler_removed_item_race.py": 15.49, - "tests/integration/test_scheduler_self_keyed.py": 25.77, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84, - "tests/integration/test_scheduler_string_test.py": 15.42, - "tests/integration/test_script_array_params.py": 12.73, - "tests/integration/test_script_delay_params.py": 12.69, - "tests/integration/test_script_queued.py": 20.38, - "tests/integration/test_script_queued_idle_loop.py": 25.06, - "tests/integration/test_script_wait_on_boot.py": 15.67, - "tests/integration/test_select_stringref_trigger.py": 19.48, - "tests/integration/test_sensor_filters_delta.py": 27.62, - "tests/integration/test_sensor_filters_ring_buffer.py": 20.27, - "tests/integration/test_sensor_filters_sliding_window.py": 56.28, - "tests/integration/test_sensor_filters_value_list.py": 20.6, - "tests/integration/test_sensor_timeout_filter.py": 22.21, - "tests/integration/test_socket_wake_gate_tcp.py": 16.37, - "tests/integration/test_status_flags.py": 29.68, - "tests/integration/test_strftime_to.py": 17.42, - "tests/integration/test_syslog.py": 18.39, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61, - "tests/integration/test_template_text_save.py": 19.16, - "tests/integration/test_text_command.py": 16.43, - "tests/integration/test_text_sensor_raw_state.py": 17.19, - "tests/integration/test_uart_mock_ld2410.py": 37.0, - "tests/integration/test_uart_mock_ld2412.py": 40.82, - "tests/integration/test_uart_mock_ld2420.py": 32.7, - "tests/integration/test_uart_mock_ld2450.py": 32.84, - "tests/integration/test_uart_mock_modbus.py": 548.87, - "tests/integration/test_udp.py": 16.67, - "tests/integration/test_use_address_runtime.py": 27.26, - "tests/integration/test_valve_control_action.py": 24.58, - "tests/integration/test_varint_five_byte_device_id.py": 22.5, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.05, - "tests/integration/test_wait_until_on_boot.py": 10.37, - "tests/integration/test_wait_until_ordering.py": 18.23, - "tests/integration/test_wait_until_reentrant_restart.py": 19.35, - "tests/integration/test_wake_loop_forces_phase_b.py": 17.83, - "tests/integration/test_water_heater_template.py": 25.7 + "tests/integration/test_action_concurrent_reentry.py": 30.48, + "tests/integration/test_addressable_light_transition.py": 42.1, + "tests/integration/test_alarm_control_panel_state_transitions.py": 35.76, + "tests/integration/test_api_action_metadata.py": 22.35, + "tests/integration/test_api_action_responses.py": 30.31, + "tests/integration/test_api_action_timeout.py": 34.73, + "tests/integration/test_api_conditional_memory.py": 18.35, + "tests/integration/test_api_custom_services.py": 15.99, + "tests/integration/test_api_get_time_response_timezone.py": 24.21, + "tests/integration/test_api_homeassistant.py": 33.77, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55, + "tests/integration/test_api_list_entities_backpressure.py": 23.04, + "tests/integration/test_api_message_size_batching.py": 27.31, + "tests/integration/test_api_reboot_timeout.py": 29.32, + "tests/integration/test_api_string_lambda.py": 14.9, + "tests/integration/test_api_vv_logging.py": 26.25, + "tests/integration/test_api_zero_psk_provisioning.py": 38.19, + "tests/integration/test_areas_and_devices.py": 27.52, + "tests/integration/test_automation_wait_actions.py": 24.25, + "tests/integration/test_automations.py": 36.02, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47, + "tests/integration/test_binary_sensor_invalidate_state.py": 14.79, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52, + "tests/integration/test_build_info.py": 21.42, + "tests/integration/test_camera_mock.py": 17.02, + "tests/integration/test_climate_control_action.py": 26.56, + "tests/integration/test_climate_custom_modes.py": 18.82, + "tests/integration/test_continuation_actions.py": 20.39, + "tests/integration/test_cover_control_action.py": 19.91, + "tests/integration/test_crc8_helper.py": 16.73, + "tests/integration/test_device_id_in_state.py": 58.41, + "tests/integration/test_duplicate_entities.py": 30.76, + "tests/integration/test_entity_icon.py": 25.34, + "tests/integration/test_fan_turn_on_action.py": 23.64, + "tests/integration/test_fnv1_hash_object_id.py": 25.44, + "tests/integration/test_fnv1a_hash.py": 20.85, + "tests/integration/test_gpio_expander_cache.py": 14.42, + "tests/integration/test_host_logger_thread_safety.py": 21.31, + "tests/integration/test_host_mode_basic.py": 2.65, + "tests/integration/test_host_mode_batch_delay.py": 22.21, + "tests/integration/test_host_mode_climate_basic_state.py": 27.12, + "tests/integration/test_host_mode_climate_control.py": 21.57, + "tests/integration/test_host_mode_empty_string_options.py": 27.17, + "tests/integration/test_host_mode_entity_fields.py": 30.1, + "tests/integration/test_host_mode_fan_preset.py": 17.55, + "tests/integration/test_host_mode_many_entities.py": 38.98, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82, + "tests/integration/test_host_mode_noise_encryption.py": 39.84, + "tests/integration/test_host_mode_reconnect.py": 13.1, + "tests/integration/test_host_mode_sensor.py": 22.17, + "tests/integration/test_host_ota.py": 92.05, + "tests/integration/test_host_preferences.py": 20.29, + "tests/integration/test_host_preferences_suspend_resume.py": 15.02, + "tests/integration/test_improv_serial_uart.py": 30.15, + "tests/integration/test_large_message_batching.py": 25.84, + "tests/integration/test_legacy_area.py": 21.24, + "tests/integration/test_legacy_climate_compat.py": 17.34, + "tests/integration/test_legacy_fan_compat.py": 22.6, + "tests/integration/test_light_automations.py": 29.13, + "tests/integration/test_light_binary_effect_off_phase.py": 33.99, + "tests/integration/test_light_calls.py": 26.81, + "tests/integration/test_light_constant_brightness.py": 25.0, + "tests/integration/test_light_control_action.py": 25.57, + "tests/integration/test_light_dim_relative_action.py": 21.4, + "tests/integration/test_light_effect_zero_brightness.py": 19.65, + "tests/integration/test_light_initial_state.py": 17.58, + "tests/integration/test_light_toggle_action.py": 28.28, + "tests/integration/test_lock_automations.py": 23.3, + "tests/integration/test_logger_buffered_recursion_guard.py": 22.96, + "tests/integration/test_loop_disable_enable.py": 16.18, + "tests/integration/test_loop_interval_decoupling.py": 25.19, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59, + "tests/integration/test_lvgl_headless_render.py": 87.78, + "tests/integration/test_micros_to_millis.py": 18.73, + "tests/integration/test_multi_click_trigger.py": 24.2, + "tests/integration/test_multi_device_preferences.py": 20.52, + "tests/integration/test_noise_encryption_key_protection.py": 19.1, + "tests/integration/test_object_id_api_verification.py": 26.24, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88, + "tests/integration/test_object_id_no_friendly_name.py": 61.27, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03, + "tests/integration/test_online_image_bmp.py": 34.21, + "tests/integration/test_oversized_payloads.py": 62.75, + "tests/integration/test_preference_key_stability.py": 26.8, + "tests/integration/test_runtime_stats.py": 28.26, + "tests/integration/test_safe_mode_loop_runs.py": 18.14, + "tests/integration/test_scheduler_blocking_warning.py": 28.7, + "tests/integration/test_scheduler_bulk_cleanup.py": 20.73, + "tests/integration/test_scheduler_defer_cancel.py": 22.99, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.97, + "tests/integration/test_scheduler_defer_fifo_simple.py": 24.15, + "tests/integration/test_scheduler_defer_stress.py": 23.11, + "tests/integration/test_scheduler_heap_stress.py": 20.2, + "tests/integration/test_scheduler_internal_id_no_collision.py": 23.75, + "tests/integration/test_scheduler_interval_reschedule.py": 15.32, + "tests/integration/test_scheduler_interval_zero_coerced.py": 20.1, + "tests/integration/test_scheduler_null_name.py": 17.43, + "tests/integration/test_scheduler_numeric_id_test.py": 25.51, + "tests/integration/test_scheduler_pool.py": 24.22, + "tests/integration/test_scheduler_rapid_cancellation.py": 24.01, + "tests/integration/test_scheduler_recursive_timeout.py": 22.94, + "tests/integration/test_scheduler_removed_item_race.py": 23.07, + "tests/integration/test_scheduler_self_keyed.py": 18.43, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99, + "tests/integration/test_scheduler_string_test.py": 17.27, + "tests/integration/test_script_array_params.py": 4.59, + "tests/integration/test_script_delay_params.py": 22.46, + "tests/integration/test_script_queued.py": 25.24, + "tests/integration/test_script_queued_idle_loop.py": 5.04, + "tests/integration/test_script_wait_on_boot.py": 21.77, + "tests/integration/test_sdl_headless_screenshot.py": 19.23, + "tests/integration/test_select_stringref_trigger.py": 19.31, + "tests/integration/test_sensor_filters_delta.py": 25.92, + "tests/integration/test_sensor_filters_ring_buffer.py": 22.39, + "tests/integration/test_sensor_filters_sliding_window.py": 57.93, + "tests/integration/test_sensor_filters_value_list.py": 20.32, + "tests/integration/test_sensor_timeout_filter.py": 25.35, + "tests/integration/test_snapshot_display.py": 19.7, + "tests/integration/test_socket_wake_gate_tcp.py": 14.5, + "tests/integration/test_status_flags.py": 33.83, + "tests/integration/test_strftime_to.py": 17.64, + "tests/integration/test_syslog.py": 24.49, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81, + "tests/integration/test_template_climate_basic.py": 15.28, + "tests/integration/test_template_climate_custom_modes.py": 25.07, + "tests/integration/test_template_climate_nonoptimistic.py": 24.25, + "tests/integration/test_template_climate_on_control_ordering.py": 24.09, + "tests/integration/test_template_climate_publish_all_fields.py": 17.78, + "tests/integration/test_template_climate_sensor_push.py": 17.42, + "tests/integration/test_template_climate_set_actions.py": 23.63, + "tests/integration/test_template_climate_two_point_temperature.py": 25.19, + "tests/integration/test_template_text_save.py": 17.88, + "tests/integration/test_text_command.py": 22.71, + "tests/integration/test_text_sensor_raw_state.py": 25.17, + "tests/integration/test_uart_mock_ld2410.py": 58.15, + "tests/integration/test_uart_mock_ld2412.py": 61.14, + "tests/integration/test_uart_mock_ld2420.py": 33.87, + "tests/integration/test_uart_mock_ld2450.py": 26.06, + "tests/integration/test_uart_mock_modbus.py": 391.79, + "tests/integration/test_udp.py": 7.38, + "tests/integration/test_use_address_runtime.py": 24.09, + "tests/integration/test_valve_control_action.py": 23.22, + "tests/integration/test_varint_five_byte_device_id.py": 17.93, + "tests/integration/test_wait_until_mid_loop_timing.py": 22.26, + "tests/integration/test_wait_until_on_boot.py": 17.46, + "tests/integration/test_wait_until_ordering.py": 11.89, + "tests/integration/test_wait_until_reentrant_restart.py": 22.88, + "tests/integration/test_wake_loop_forces_phase_b.py": 16.6, + "tests/integration/test_water_heater_template.py": 19.66 } From c66fa812086f20aead9e56bf42b15f541b2e5bc8 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:09:49 +1200 Subject: [PATCH 073/759] [core] Consolidate setup scripts into a cross-platform setup.py (#18856) --- script/git-hooks/post-checkout | 46 ++- script/setup | 74 +---- script/setup.bat | 29 +- script/setup.py | 222 +++++++++++++ tests/script/test_setup.py | 562 +++++++++++++++++++++++++++++++++ 5 files changed, 827 insertions(+), 106 deletions(-) create mode 100755 script/setup.py create mode 100644 tests/script/test_setup.py diff --git a/script/git-hooks/post-checkout b/script/git-hooks/post-checkout index 853c2b03521..73c1cb0f130 100755 --- a/script/git-hooks/post-checkout +++ b/script/git-hooks/post-checkout @@ -1,27 +1,49 @@ #!/bin/sh # Prepare the dev environment for a new checkout or worktree. # -# Installed into the git hooks directory by script/setup. Deliberately tiny and -# self-contained: it stays valid on branches where script/setup does not exist, -# and simply does nothing there. +# Installed into the git hooks directory by script/setup.py. Deliberately tiny +# and self-contained: it stays valid on branches where the setup script does not +# exist, and simply does nothing there. # $3 is 1 for a branch checkout, 0 for a file checkout. [ "$3" = "1" ] || exit 0 top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -# This also runs on ordinary branch switches, where there is nothing to do. +# This also runs on ordinary branch switches, where there is nothing to do. Both +# layouts are checked because git for Windows runs hooks under its own bundled +# shell, where the environment lives in venv/Scripts rather than venv/bin. [ -x "$top/venv/bin/python" ] && exit 0 -[ -x "$top/script/setup" ] || exit 0 +[ -f "$top/venv/Scripts/python.exe" ] && exit 0 + +# Branches from before the setup script moved to Python carry only the shell +# entry point, so whichever one the checked out branch has is used. +py= +if [ -f "$top/script/setup.py" ]; then + # The interpreter goes by different names across platforms, and on Windows + # "python3" is often a stub that opens the app store instead of running + # anything, so each candidate is tried before it is used. Doing nothing is the + # right outcome when none of them work. + for candidate in "python3" "python" "py -3"; do + # Unquoted on purpose: the launcher candidate is a command plus a flag. + if $candidate -c "" >/dev/null 2>&1; then + py=$candidate + break + fi + done + [ -n "$py" ] || exit 0 +elif ! [ -x "$top/script/setup" ]; then + exit 0 +fi # Every worktree shares the hooks directory of the checkout it was created -# from, and the script/setup run below is the one from whichever branch was just +# from, and the setup script run below is the one from whichever branch was just # checked out. Older branches install their own pre-commit hook without checking # for a worktree: that moves the shared hook aside as pre-commit.legacy and # replaces it with one tied to this worktree's virtual environment, so commits # break in every checkout. To rule that out, the hooks directory is copied -# before script/setup runs and put back exactly as it was afterwards, including -# removing any file script/setup added. +# before the setup script runs and put back exactly as it was afterwards, +# including removing any file the setup script added. hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0 snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0 cp -p "$hooks"/* "$snap"/ 2>/dev/null @@ -29,7 +51,13 @@ cp -p "$hooks"/* "$snap"/ 2>/dev/null # Clear VIRTUAL_ENV so a checkout made from a shell with an environment already # activated still gets its own, rather than having the active one repointed at # this working tree. -env -u VIRTUAL_ENV "$top/script/setup" +unset VIRTUAL_ENV +if [ -n "$py" ]; then + # Unquoted on purpose, as above. + $py "$top/script/setup.py" +else + "$top/script/setup" +fi status=$? for f in "$hooks"/*; do diff --git a/script/setup b/script/setup index b96af6e8f34..91bcb881541 100755 --- a/script/setup +++ b/script/setup @@ -1,71 +1,7 @@ #!/usr/bin/env bash -# Set up ESPHome dev environment +# Set up ESPHome dev environment. +# +# The work is done by setup.py, which script/setup.bat also runs, so the Unix +# and Windows entry points share one implementation. -set -e - -cd "$(dirname "$0")/.." -if [ -n "$VIRTUAL_ENV" ]; then - # A virtual environment is already active (e.g. the devcontainer's pre-provisioned - # esphome-venv). Install into it rather than creating a ./venv in the workspace. - venv_state=active -elif [ -x venv/bin/python ]; then - # Reuse the environment from an earlier run, so this script can be run again - # at any time to pick up dependency changes. - venv_state=reused - source venv/bin/activate -else - venv_state=created - # --clear replaces a partial environment left behind by an interrupted run. - if [ -x "$(command -v uv)" ]; then - uv venv --clear --seed venv - else - python3 -m venv --clear venv - fi - source venv/bin/activate -fi - -if ! [ -x "$(command -v uv)" ]; then - python3 -m pip install uv -fi - -uv pip install setuptools wheel -uv pip install -e ".[dev,test]" --config-settings editable_mode=compat - -# A worktree shares one git hooks directory with the main checkout it was -# created from, so hooks are installed from the main checkout only. Installing -# from a worktree would point the shared hook at that worktree's virtual -# environment, breaking it for everyone once the worktree is removed. -git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" -common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" -if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then - # --overwrite replaces any hook already in place. Without it, prek finds a - # previously installed pre-commit hook, moves it aside to - # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would - # run both tools. - prek install --overwrite - - # Prepares the virtual environment for new checkouts and worktrees. Installed - # once here, it covers every worktree created from this checkout. - if [ -d "$common_dir/hooks" ]; then - cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout" - chmod +x "$common_dir/hooks/post-checkout" - fi -fi - -mkdir -p .temp - -echo -echo -case "$venv_state" in - created) - echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." - ;; - reused) - echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it." - ;; - active) - echo "Dependencies installed into the active virtual environment:" - echo " $VIRTUAL_ENV" - echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." - ;; -esac +exec python3 "$(dirname "$0")/setup.py" "$@" diff --git a/script/setup.bat b/script/setup.bat index 809d05ae933..405121b1390 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -1,28 +1 @@ -@echo off - -if defined VIRTUAL_ENV goto :install - -echo Starting the Virtual Environment -python -m venv venv -call venv/Scripts/activate -echo Running the Virtual Environment - -:install - -echo Installing required packages... - -python.exe -m pip install --upgrade pip - -pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt -pip3 install setuptools wheel -pip3 install -e ".[dev,test]" --config-settings editable_mode=compat - -rem --overwrite replaces any hook already in place. Without it, prek finds a -rem previously installed pre-commit hook, moves it aside to -rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would -rem run both tools. -prek install --overwrite - -echo . -echo . -echo Virtual environment created. Run 'venv/Scripts/activate' to use it. +@python "%~dp0setup.py" %* diff --git a/script/setup.py b/script/setup.py new file mode 100755 index 00000000000..62129b8c050 --- /dev/null +++ b/script/setup.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Set up the ESPHome development environment. + +Shared implementation behind script/setup and script/setup.bat, so the Unix and +Windows entry points cannot drift apart. Uses only the standard library: it runs +before any dependency has been installed. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig + +MIN_PYTHON = (3, 12) + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_VENV = ROOT / "venv" +POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout" + +# State of the environment the dependencies end up in, used for the closing +# message. +VENV_ACTIVE = "active" +VENV_REUSED = "reused" +VENV_CREATED = "created" + + +def bin_dir(venv: Path) -> Path: + """Return the directory holding a virtual environment's executables. + + The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the + layout does not have to be hardcoded here. + """ + base = str(venv) + return Path( + sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base}) + ) + + +def venv_python(venv: Path) -> Path: + """Return the path to a virtual environment's interpreter.""" + name = "python.exe" if os.name == "nt" else "python" + return bin_dir(venv) / name + + +def run(command: list[str], env: dict[str, str] | None = None) -> None: + """Run a command, aborting the whole script if it fails.""" + print(f"+ {' '.join(command)}", flush=True) + result = subprocess.run(command, cwd=ROOT, env=env, check=False) + if result.returncode != 0: + # Some tools fail without printing anything, so name the step that broke. + print( + f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr + ) + raise SystemExit(result.returncode) + + +def git_output(*args: str) -> str: + """Return the trimmed output of a git command, or "" if it cannot be run.""" + try: + result = subprocess.run( + ["git", *args], cwd=ROOT, capture_output=True, text=True, check=False + ) + except OSError: + # Git is not required to install the dependencies, only to install hooks. + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def create_venv(venv: Path) -> None: + """Create a virtual environment, replacing anything already at the path.""" + # --clear replaces a partial environment left behind by an interrupted run. + if (uv := shutil.which("uv")) is not None: + run([uv, "venv", "--clear", "--seed", str(venv)]) + else: + run([sys.executable, "-m", "venv", "--clear", str(venv)]) + + +def venv_environment(venv: Path) -> dict[str, str]: + """Return the environment child processes need to target a virtual env. + + Equivalent to sourcing the environment's activate script: tools such as uv + and prek pick the environment up from VIRTUAL_ENV and PATH. + """ + env = dict(os.environ) + env["VIRTUAL_ENV"] = str(venv) + env.pop("PYTHONHOME", None) + path = str(bin_dir(venv)) + # An empty entry would be appended if PATH is unset, and on Unix that means + # the working directory is searched for executables. + if existing := env.get("PATH"): + path = os.pathsep.join([path, existing]) + env["PATH"] = path + return env + + +def find_uv(venv: Path, env: dict[str, str]) -> str: + """Return the path to uv, installing it into the environment if needed.""" + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env) + if (uv := shutil.which("uv", path=env["PATH"])) is not None: + return uv + raise SystemExit("uv could not be installed, aborting.") + + +def install_dependencies(venv: Path, env: dict[str, str]) -> None: + """Install ESPHome and its development dependencies into the environment.""" + uv = find_uv(venv, env) + run([uv, "pip", "install", "setuptools", "wheel"], env=env) + # The dev and test extras pull in requirements_dev.txt and + # requirements_test.txt, and the package itself pulls in requirements.txt, + # so this single install covers every requirements file. + run( + [ + uv, + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + env=env, + ) + + +def install_git_hooks(env: dict[str, str]) -> None: + """Install the git hooks, but only when run from the main checkout. + + A worktree shares one git hooks directory with the main checkout it was + created from. Installing from a worktree would point the shared hook at that + worktree's virtual environment, breaking it for everyone once the worktree is + removed. + """ + git_dir = git_output("rev-parse", "--absolute-git-dir") + common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir") + if not git_dir or not common_dir or Path(git_dir) != Path(common_dir): + return + + prek = shutil.which("prek", path=env["PATH"]) + if prek is None: + raise SystemExit("prek was not installed, aborting.") + # --overwrite replaces any hook already in place. Without it, prek finds a + # previously installed pre-commit hook, moves it aside to + # .git/hooks/pre-commit.legacy and keeps calling it, so every commit would + # run both tools. + run([prek, "install", "--overwrite"], env=env) + + # Prepares the virtual environment for new checkouts and worktrees. Installed + # once here, it covers every worktree created from this checkout. + hooks_dir = Path(common_dir) / "hooks" + if hooks_dir.is_dir(): + installed = hooks_dir / "post-checkout" + shutil.copyfile(POST_CHECKOUT_HOOK, installed) + installed.chmod(0o755) + + +def activate_hint() -> str: + """Return the command that activates the environment this script creates.""" + activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate" + if os.name == "nt": + return str(activate) + return f"source {activate.as_posix()}" + + +def report(state: str, venv: Path) -> None: + """Print the closing message for the environment that was set up.""" + location = f"./{DEFAULT_VENV.name}" + print() + print() + if state == VENV_ACTIVE: + print("Dependencies installed into the active virtual environment:") + print(f" {venv}") + print( + f"It is already active in this shell, so no '{activate_hint()}' is needed." + ) + elif state == VENV_REUSED: + print( + f"Dependencies updated in the existing {location}. " + f"Run '{activate_hint()}' to use it." + ) + else: + print( + f"Virtual environment created at {location}. " + f"Run '{activate_hint()}' to use it." + ) + + +def main() -> None: + """Set up the development environment.""" + if sys.version_info < MIN_PYTHON: + raise SystemExit( + f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, " + f"but this is Python {sys.version.split()[0]}." + ) + + # A virtual environment that is already active (for example the + # devcontainer's pre-provisioned esphome-venv) is installed into rather than + # creating a ./venv in the workspace. + if active := os.environ.get("VIRTUAL_ENV"): + state, venv = VENV_ACTIVE, Path(active) + elif venv_python(DEFAULT_VENV).is_file(): + # Reuse the environment from an earlier run, so this script can be run + # again at any time to pick up dependency changes. + state, venv = VENV_REUSED, DEFAULT_VENV + else: + state, venv = VENV_CREATED, DEFAULT_VENV + create_venv(venv) + + env = venv_environment(venv) + install_dependencies(venv, env) + install_git_hooks(env) + (ROOT / ".temp").mkdir(exist_ok=True) + report(state, venv) + + +if __name__ == "__main__": + main() diff --git a/tests/script/test_setup.py b/tests/script/test_setup.py new file mode 100644 index 00000000000..3e816c4b05d --- /dev/null +++ b/tests/script/test_setup.py @@ -0,0 +1,562 @@ +"""Tests for script/setup.py.""" + +import importlib.util +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import runpy +import sys +from types import ModuleType +from unittest.mock import Mock, call, patch + +import pytest + +_SCRIPT = Path(__file__).parents[2] / "script" / "setup.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("script_setup", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def script_setup() -> ModuleType: + """Fresh import of script/setup.py, isolated from other tests.""" + return _load_module() + + +# --- bin_dir / venv_python / activate_hint ----------------------------------- + + +def test_bin_dir_matches_host_layout(script_setup: ModuleType, tmp_path: Path) -> None: + """The venv scheme resolves to Scripts on Windows and bin everywhere else.""" + expected = "Scripts" if os.name == "nt" else "bin" + assert script_setup.bin_dir(tmp_path) == tmp_path / expected + + +# Both flavours are exercised on every host. Pure paths are used because a real +# Path refuses to change flavour: PosixPath cannot be built on Windows, and +# WindowsPath cannot be built on Unix. + + +def test_venv_python_posix(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PurePosixPath("/x/venv/bin/python") + + +def test_venv_python_nt(script_setup: ModuleType, tmp_path: Path) -> None: + with ( + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + result = script_setup.venv_python(tmp_path) + assert result == PureWindowsPath(r"C:\x\venv\Scripts\python.exe") + + +def test_activate_hint_posix(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PurePosixPath("/x")), + patch.object( + script_setup, "bin_dir", return_value=PurePosixPath("/x/venv/bin") + ), + patch.object(script_setup.os, "name", "posix"), + ): + hint = script_setup.activate_hint() + assert hint == "source venv/bin/activate" + + +def test_activate_hint_nt(script_setup: ModuleType) -> None: + with ( + patch.object(script_setup, "ROOT", PureWindowsPath(r"C:\x")), + patch.object( + script_setup, "bin_dir", return_value=PureWindowsPath(r"C:\x\venv\Scripts") + ), + patch.object(script_setup.os, "name", "nt"), + ): + hint = script_setup.activate_hint() + # The nt branch returns str(activate) as-is, skipping the "source " prefix. + assert hint == r"venv\Scripts\activate" + + +# --- run ----------------------------------------------------------------- + + +def test_run_success(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run: + script_setup.run(["echo", "hi"]) + mock_run.assert_called_once_with( + ["echo", "hi"], cwd=script_setup.ROOT, env=None, check=False + ) + + +def test_run_failure_raises_system_exit_with_code( + script_setup: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=7)), + pytest.raises(SystemExit) as excinfo, + ): + script_setup.run(["false"]) + assert excinfo.value.code == 7 + assert "Failed with exit code 7: false" in capsys.readouterr().err + + +# --- git_output ------------------------------------------------------------ + + +def test_git_output_success_strips_stdout(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=0, stdout=" /repo/.git \n"), + ) as mock_run: + result = script_setup.git_output("rev-parse", "--absolute-git-dir") + assert result == "/repo/.git" + mock_run.assert_called_once_with( + ["git", "rev-parse", "--absolute-git-dir"], + cwd=script_setup.ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_git_output_nonzero_returncode_is_empty(script_setup: ModuleType) -> None: + with patch.object( + script_setup.subprocess, + "run", + return_value=Mock(returncode=1, stdout="whatever"), + ): + assert script_setup.git_output("status") == "" + + +def test_git_output_oserror_is_empty(script_setup: ModuleType) -> None: + with patch.object(script_setup.subprocess, "run", side_effect=OSError("no git")): + assert script_setup.git_output("status") == "" + + +# --- create_venv ----------------------------------------------------------- + + +def test_create_venv_uses_uv_when_present( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + ["/usr/bin/uv", "venv", "--clear", "--seed", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +def test_create_venv_falls_back_to_venv_module( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + with ( + patch.object(script_setup.shutil, "which", return_value=None), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.create_venv(venv) + mock_run.assert_called_once_with( + [sys.executable, "-m", "venv", "--clear", str(venv)], + cwd=script_setup.ROOT, + env=None, + check=False, + ) + + +# --- venv_environment -------------------------------------------------------- + + +def test_venv_environment_sets_virtual_env_and_prepends_path( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.setenv("PYTHONHOME", "/somewhere") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + env = script_setup.venv_environment(venv) + assert env["VIRTUAL_ENV"] == str(venv) + assert "PYTHONHOME" not in env + expected_prefix = str(script_setup.bin_dir(venv)) + os.pathsep + assert env["PATH"] == expected_prefix + "/usr/bin:/bin" + + +def test_venv_environment_path_fallback_when_unset( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + venv = tmp_path / "venv" + monkeypatch.delenv("PATH", raising=False) + env = script_setup.venv_environment(venv) + # No trailing separator: an empty PATH entry means "search the cwd". + assert env["PATH"] == str(script_setup.bin_dir(venv)) + + +# --- find_uv ----------------------------------------------------------------- + + +def test_find_uv_found_immediately(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_not_called() + + +def test_find_uv_installed_then_found(script_setup: ModuleType, tmp_path: Path) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, "/usr/bin/uv"]), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + result = script_setup.find_uv(venv, env) + assert result == "/usr/bin/uv" + mock_run.assert_called_once_with( + [str(script_setup.venv_python(venv)), "-m", "pip", "install", "uv"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + + +def test_find_uv_still_missing_raises_system_exit( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", side_effect=[None, None]), + patch.object(script_setup.subprocess, "run", return_value=Mock(returncode=0)), + pytest.raises(SystemExit, match="uv could not be installed"), + ): + script_setup.find_uv(venv, env) + + +# --- install_dependencies ----------------------------------------------------- + + +def test_install_dependencies_installs_setuptools_then_project( + script_setup: ModuleType, tmp_path: Path +) -> None: + venv = tmp_path / "venv" + env = {"PATH": "/usr/bin"} + with ( + patch.object(script_setup.shutil, "which", return_value="/usr/bin/uv"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_dependencies(venv, env) + assert mock_run.call_args_list == [ + call( + ["/usr/bin/uv", "pip", "install", "setuptools", "wheel"], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + call( + [ + "/usr/bin/uv", + "pip", + "install", + "-e", + ".[dev,test]", + "--config-settings", + "editable_mode=compat", + ], + cwd=script_setup.ROOT, + env=env, + check=False, + ), + ] + + +# --- install_git_hooks --------------------------------------------------------- + + +def _fake_git_output(git_dir: str, common_dir: str): + def _run(*args: str) -> str: + if "--absolute-git-dir" in args: + return git_dir + return common_dir + + return _run + + +def test_install_git_hooks_returns_early_when_git_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("", "/repo/.git") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_when_common_dir_empty( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, "git_output", side_effect=_fake_git_output("/repo/.git", "") + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_returns_early_for_worktree( + script_setup: ModuleType, +) -> None: + """A worktree's git-dir differs from the shared common-dir.""" + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git/worktrees/wt", "/repo/.git"), + ), + patch.object(script_setup.subprocess, "run") as mock_run, + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_missing_prek_raises_system_exit( + script_setup: ModuleType, +) -> None: + env = {"PATH": "/usr/bin"} + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output("/repo/.git", "/repo/.git"), + ), + patch.object(script_setup.shutil, "which", return_value=None), + patch.object(script_setup.subprocess, "run") as mock_run, + pytest.raises(SystemExit, match="prek was not installed"), + ): + script_setup.install_git_hooks(env) + mock_run.assert_not_called() + + +def test_install_git_hooks_happy_path_installs_hook( + script_setup: ModuleType, tmp_path: Path +) -> None: + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + hooks_dir = common_dir / "hooks" + hooks_dir.mkdir(parents=True) + source_hook = tmp_path / "post-checkout" + source_hook.write_text("#!/bin/sh\necho post-checkout\n") + + with ( + patch.object(script_setup, "POST_CHECKOUT_HOOK", source_hook), + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once_with( + ["/usr/bin/prek", "install", "--overwrite"], + cwd=script_setup.ROOT, + env=env, + check=False, + ) + installed = hooks_dir / "post-checkout" + assert installed.read_text() == source_hook.read_text() + if os.name != "nt": + # Windows has no POSIX permission bits for chmod to set. + assert (installed.stat().st_mode & 0o777) == 0o755 + + +def test_install_git_hooks_skips_copy_when_hooks_dir_missing( + script_setup: ModuleType, tmp_path: Path +) -> None: + """The prek install still runs when the hooks directory does not exist.""" + env = {"PATH": "/usr/bin"} + common_dir = tmp_path / "repo" / ".git" + common_dir.mkdir(parents=True) # no "hooks" subdirectory created + + with ( + patch.object( + script_setup, + "git_output", + side_effect=_fake_git_output(str(common_dir), str(common_dir)), + ), + patch.object(script_setup.shutil, "which", return_value="/usr/bin/prek"), + patch.object( + script_setup.subprocess, "run", return_value=Mock(returncode=0) + ) as mock_run, + ): + script_setup.install_git_hooks(env) + + mock_run.assert_called_once() + assert not (common_dir / "hooks").exists() + + +# --- report ------------------------------------------------------------------ + + +def test_report_active_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + venv = Path("/opt/esphome-venv") + script_setup.report(script_setup.VENV_ACTIVE, venv) + out = capsys.readouterr().out + assert "Dependencies installed into the active virtual environment:" in out + assert str(venv) in out + assert "is already active in this shell" in out + + +def test_report_reused_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_REUSED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Dependencies updated in the existing ./venv" in out + + +def test_report_created_state( + script_setup: ModuleType, capsys: pytest.CaptureFixture +) -> None: + script_setup.report(script_setup.VENV_CREATED, script_setup.DEFAULT_VENV) + out = capsys.readouterr().out + assert "Virtual environment created at ./venv" in out + + +# --- main -------------------------------------------------------------------- + + +def test_main_raises_system_exit_when_python_too_old( + script_setup: ModuleType, +) -> None: + with ( + patch.object(script_setup.sys, "version_info", (3, 11, 5)), + pytest.raises(SystemExit, match="ESPHome needs Python 3.12"), + ): + script_setup.main() + + +def test_main_uses_active_virtual_env( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + active_venv = tmp_path / "active-venv" + monkeypatch.setenv("VIRTUAL_ENV", str(active_venv)) + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_ACTIVE, active_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_reuses_existing_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" + python_path = script_setup.venv_python(default_venv) + python_path.parent.mkdir(parents=True) + python_path.touch() + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_not_called() + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_REUSED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_main_creates_new_venv( + script_setup: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + default_venv = tmp_path / "venv" # does not exist yet + + with ( + patch.object(script_setup, "ROOT", tmp_path), + patch.object(script_setup, "DEFAULT_VENV", default_venv), + patch.object(script_setup, "create_venv") as mock_create_venv, + patch.object(script_setup, "install_dependencies") as mock_install_deps, + patch.object(script_setup, "install_git_hooks") as mock_install_hooks, + patch.object(script_setup, "report") as mock_report, + ): + script_setup.main() + mock_create_venv.assert_called_once_with(default_venv) + mock_install_deps.assert_called_once() + mock_install_hooks.assert_called_once() + mock_report.assert_called_once_with(script_setup.VENV_CREATED, default_venv) + assert (tmp_path / ".temp").is_dir() + + +def test_run_as_script_calls_main(tmp_path: Path) -> None: + """The __main__ guard runs the whole flow, with every side effect stubbed.""" + completed = Mock(returncode=0, stdout="") + with ( + patch("subprocess.run", return_value=completed) as mock_run, + patch("shutil.which", return_value="/usr/bin/uv"), + patch("pathlib.Path.mkdir") as mock_mkdir, + patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path / "env")}), + ): + runpy.run_path(str(_SCRIPT), run_name="__main__") + + # The dependency install ran, and git reported no hooks directory to touch. + assert mock_run.called + mock_mkdir.assert_called_once_with(exist_ok=True) From 4868b498cf80cf6fb6c59544a84797f74736bbfe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:09:58 +1200 Subject: [PATCH 074/759] [ci] Ask stale PR authors to merge dev instead of rebasing (#19064) --- .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 aa31094f81d..38d2418ac62 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -33,7 +33,7 @@ jobs: and will be closed if no further activity occurs within 7 days. If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev + to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. Thank you for your contribution! From 5ed59af9204af5e4a4638d10379375b598dd8ace Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:11:13 +1200 Subject: [PATCH 075/759] [template] Surface value metadata on template entity forms (#17545) --- .../template/binary_sensor/__init__.py | 14 +++- .../components/template/button/__init__.py | 6 +- esphome/components/template/cover/__init__.py | 7 +- esphome/components/template/event/__init__.py | 6 +- .../components/template/number/__init__.py | 9 ++- .../components/template/sensor/__init__.py | 22 +++++- .../components/template/switch/__init__.py | 7 +- .../template/text_sensor/__init__.py | 8 +- esphome/components/template/valve/__init__.py | 7 +- esphome/config_validation.py | 32 ++++++++ .../template/test_template_visibility.py | 76 +++++++++++++++++++ tests/unit_tests/test_config_validation.py | 29 +++++++ 12 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 tests/component_tests/template/test_template_visibility.py diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 8f57df91c51..07028f7dffc 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -2,7 +2,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_CONDITION, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, +) from esphome.cpp_generator import LambdaExpression from .. import template_ns @@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_( ) CONFIG_SCHEMA = ( - binary_sensor.binary_sensor_schema(TemplateBinarySensor) + cv.with_visibility( + binary_sensor.binary_sensor_schema(TemplateBinarySensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda, diff --git a/esphome/components/template/button/__init__.py b/esphome/components/template/button/__init__.py index e0101dfc8f3..9c6fa13c19b 100644 --- a/esphome/components/template/button/__init__.py +++ b/esphome/components/template/button/__init__.py @@ -1,10 +1,14 @@ from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE_CLASS from .. import template_ns TemplateButton = template_ns.class_("TemplateButton", button.Button) -CONFIG_SCHEMA = button.button_schema(TemplateButton) +CONFIG_SCHEMA = cv.with_visibility( + button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS +) async def to_code(config): diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 7cb50df84c5..0e6f96e9f5b 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -38,7 +39,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - cover.cover_schema(TemplateCover) + cv.with_visibility( + cover.cover_schema(TemplateCover), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/components/template/event/__init__.py b/esphome/components/template/event/__init__.py index cf9c7f4c3df..bdcbd456d5d 100644 --- a/esphome/components/template/event/__init__.py +++ b/esphome/components/template/event/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import event import esphome.config_validation as cv -from esphome.const import CONF_EVENT_TYPES +from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES from .. import template_ns @@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"] TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component) -CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend( +CONFIG_SCHEMA = cv.with_visibility( + event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS +).extend( { cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict), } diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index 2f4c9cbffe6..3b6485fec3d 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, CONF_STEP, + CONF_UNIT_OF_MEASUREMENT, ) from .. import template_ns @@ -46,7 +48,12 @@ def validate(config): CONFIG_SCHEMA = cv.All( - number.number_schema(TemplateNumber) + cv.with_visibility( + number.number_schema(TemplateNumber), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + ) .extend( { cv.Required(CONF_MAX_VALUE): cv.float_, diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 0c875bba0fb..55537a56369 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -2,7 +2,16 @@ from esphome import automation import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) from .. import template_ns @@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - sensor.sensor_schema( - TemplateSensor, - accuracy_decimals=1, + cv.with_visibility( + sensor.sensor_schema(TemplateSensor, accuracy_decimals=1), + cv.Visibility.UI, + CONF_UNIT_OF_MEASUREMENT, + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_FORCE_UPDATE, ) .extend( { diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index ca986365ede..37303abb0d7 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -4,6 +4,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC, @@ -31,7 +32,11 @@ def validate(config): CONFIG_SCHEMA = cv.All( - switch.switch_schema(TemplateSwitch) + cv.with_visibility( + switch.switch_schema(TemplateSwitch), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index ddbdd6dadb7..77f5c2ff7cf 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import text_sensor from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE from .. import template_ns @@ -12,7 +12,11 @@ TemplateTextSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - text_sensor.text_sensor_schema() + cv.with_visibility( + text_sensor.text_sensor_schema(), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.GenerateID(): cv.declare_id(TemplateTextSensor), diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a2d0c198805..11b35dad23a 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -36,7 +37,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - valve.valve_schema(TemplateValve) + cv.with_visibility( + valve.valve_schema(TemplateValve), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3f..a38fb2ed82c 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress +import copy from datetime import datetime from ipaddress import ( AddressValueError, @@ -419,6 +420,37 @@ class Required(vol.Required): self.visibility: Visibility | None = visibility +def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema: + """Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``. + + Lets a platform override the editor :class:`Visibility` of fields it + inherits from a shared schema builder — without that builder needing a + visibility parameter of its own. The canonical use is a ``template`` + platform promoting the value metadata its user is expected to define + (``device_class``, ``unit_of_measurement``, …) onto the main form: + + CONFIG_SCHEMA = cv.with_visibility( + sensor.sensor_schema(TemplateSensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + ) + + The original marker's key, default and validator are preserved; only the + visibility changes, and the input ``schema`` is left untouched. Raises if + a requested key is not present so typos fail at schema-build time. + """ + wanted = {str(k) for k in keys} + overrides = {} + for marker, validator in schema.schema.items(): + if str(marker) in wanted: + marker = copy.copy(marker) + marker.visibility = visibility + overrides[marker] = validator + if missing := wanted - {str(m) for m in overrides}: + raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}") + return schema.extend(overrides) + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" diff --git a/tests/component_tests/template/test_template_visibility.py b/tests/component_tests/template/test_template_visibility.py new file mode 100644 index 00000000000..a50a27e1f7b --- /dev/null +++ b/tests/component_tests/template/test_template_visibility.py @@ -0,0 +1,76 @@ +"""The template platforms surface value-describing metadata on the main form. + +Hardware platforms get sensible defaults for unit/device_class/etc., so those +fields fall through to the editor's advanced disclosure. A ``template`` entity +has no such defaults -- the user is expected to define them -- so the template +platforms pass ``visibility=cv.Visibility.UI`` to promote them onto the form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +@pytest.mark.parametrize( + ("platform", "fields"), + [ + ( + "sensor", + [ + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ], + ), + ("binary_sensor", ["device_class"]), + ("switch", ["device_class"]), + ("cover", ["device_class"]), + ("button", ["device_class"]), + ("valve", ["device_class"]), + ("event", ["device_class"]), + ("text_sensor", ["device_class"]), + ("number", ["device_class", "unit_of_measurement"]), + ], +) +def test_template_metadata_is_ui(platform: str, fields: list[str]) -> None: + mod = importlib.import_module(f"esphome.components.template.{platform}") + markers = _markers(mod.CONFIG_SCHEMA) + for field in fields: + assert markers[field].visibility is cv.Visibility.UI, f"{platform}.{field}" + + +def test_template_sensor_promotion_preserves_defaults() -> None: + """Promoting to UI must not drop the fields' defaults.""" + from esphome.components.template.sensor import CONFIG_SCHEMA + + markers = _markers(CONFIG_SCHEMA) + assert markers["accuracy_decimals"].default() == 1 + assert markers["force_update"].default() is False + + +def test_hardware_platform_metadata_not_promoted() -> None: + """Without ``visibility=`` the builders leave metadata unset. + + Unset markers fall through to the consumer's ``Optional`` default of + advanced, so hardware platforms are unaffected by the template promotion. + """ + from esphome.components import binary_sensor, sensor + + hw_sensor = _markers(sensor.sensor_schema(device_class="temperature")) + assert hw_sensor["device_class"].visibility is None + hw_bs = _markers(binary_sensor.binary_sensor_schema(device_class="motion")) + assert hw_bs["device_class"].visibility is None diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b8..4092b4c0d5c 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1394,6 +1394,35 @@ def test_entity_metadata_visibility_hints() -> None: assert web["web_server"].visibility is advanced +def test_with_visibility_remarks_keys() -> None: + """``with_visibility`` re-marks the named keys, preserving each field's + default and validator, without touching the other keys or the input schema. + """ + base = cv.Schema( + { + cv.Optional("a", default=7): cv.int_, + cv.Optional("b", visibility=cv.Visibility.ADVANCED): cv.string, + } + ) + promoted = cv.with_visibility(base, cv.Visibility.UI, "a") + + pm = {str(k): k for k in promoted.schema} + assert pm["a"].visibility is cv.Visibility.UI # re-marked + assert pm["a"].default() == 7 # default preserved + assert pm["b"].visibility is cv.Visibility.ADVANCED # sibling untouched + assert promoted({}) == {"a": 7} # validator/default still applied + + # The input schema is left untouched (no shared-marker mutation). + assert {str(k): k for k in base.schema}["a"].visibility is None + + +def test_with_visibility_unknown_key_raises() -> None: + """A key not present in the schema is a typo — fail at build time.""" + base = cv.Schema({cv.Optional("a"): cv.int_}) + with pytest.raises(ValueError, match="not in schema"): + cv.with_visibility(base, cv.Visibility.UI, "nope") + + def _wrap_str(value: str) -> ESPHomeDataBase: """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" return make_data_base(value) From ddbd89dd2a5bb35b24217dae93a4a45ce5da476f Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 10 Sep 2026 06:06:44 +0200 Subject: [PATCH 076/759] [network] Improve `network::is_connected()` to better handle multiple interfaces (#18999) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/network/util.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22ff..57c5a66833b 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a From 05f7d5e4f1b1d5ff14f0d4c30ce984ed319ca112 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Thu, 10 Sep 2026 10:14:43 +0600 Subject: [PATCH 077/759] [mlx90614] pec validation (#6689) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/mlx90614/mlx90614.cpp | 185 +++++++++++++++++------ esphome/components/mlx90614/mlx90614.h | 7 +- 2 files changed, 145 insertions(+), 47 deletions(-) diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2d3b6631bc6..508b3743d18 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F; static const char *const TAG = "mlx90614"; +// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures +static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; + +// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first +static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); } + void MLX90614Component::setup() { - if (!this->write_emissivity_()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + if (std::isnan(this->emissivity_)) { return; } + this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS; + this->try_write_emissivity_(); + if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } +} + +void MLX90614Component::try_write_emissivity_() { + if (this->emissivity_write_attempts_ == 0) { + return; + } + if (this->write_emissivity_()) { + this->emissivity_write_attempts_ = 0; + return; + } + if (--this->emissivity_write_attempts_ == 0) { + ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS); + this->emissivity_write_failed_ = true; + } } bool MLX90614Component::write_emissivity_() { - if (std::isnan(this->emissivity_)) + // Skip the write when the EEPROM already holds the desired value to save write cycles + uint16_t current_emissivity; + if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) { + return false; + } + + const auto desired_emissivity = static_cast(this->emissivity_ * 0xFFFF); + if (current_emissivity == desired_emissivity) { return true; - uint16_t value = (uint16_t) (this->emissivity_ * 65535); - if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) { - return false; } - delay(10); - if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) { - return false; - } - delay(10); - return true; + + return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity); } -bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { +bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) { + // The PEC covers the whole write transaction: SLA+W, command, data low, data high uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; - buf[2] = data & 0xFF; - buf[3] = data >> 8; - buf[4] = crc8(buf, 4, 0x00, 0x07, true); - return this->write_bytes(reg, buf + 2, 3); + + // See datasheet 8.3.3.1 EEPROM write sequence + // 1. Write 0x0000 into the cell of interest (erases the cell) + buf[2] = buf[3] = 0; + buf[4] = crc8_pec(buf, 4); + auto ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec); + return false; + } + + // 2. Wait at least 5ms + delay(10); + + // 3. Write the new value + if (data != 0) { + buf[2] = data & 0xFF; + buf[3] = data >> 8; + buf[4] = crc8_pec(buf, 4); + ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec); + return false; + } + // 4. Wait at least 5ms + delay(10); + } + + // 5. Read back to confirm the value was stored + uint16_t read_back; + ec = this->read_register_(reg, read_back); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec); + return false; + } + + if (read_back != data) { + ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back); + return false; + } + + return true; +} + +i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { + // The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high + uint8_t buf[6]; + buf[0] = this->address_ << 1; + buf[1] = reg; + buf[2] = (this->address_ << 1) | 0x01; + + const auto ec = this->read_register(reg, buf + 3, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "i2c read error %d", ec); + return ec; + } + + const auto expected_pec = crc8_pec(buf, 5); + if (buf[5] != expected_pec) { + ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]); + return i2c::ERROR_CRC; + } + + data = encode_uint16(buf[4], buf[3]); + return i2c::ERROR_OK; } void MLX90614Component::dump_config() { ESP_LOGCONFIG(TAG, "MLX90614:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->emissivity_write_attempts_ != 0) { + ESP_LOGW(TAG, " Emissivity not written yet, will retry"); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Ambient", this->ambient_sensor_); @@ -71,33 +156,41 @@ void MLX90614Component::dump_config() { } void MLX90614Component::update() { - uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; + // Temperature reads run regardless of the emissivity state so a failure still shows up as NAN + this->try_write_emissivity_(); + + // Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale + auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) { + if (sensor == nullptr) { + return i2c::ERROR_OK; + } + + uint16_t raw; + const auto ec = this->read_register_(reg, raw); + if (ec != i2c::ERROR_OK) { + sensor->publish_state(NAN); + return ec; + } + + // Bit 15 set means the device flagged the reading as invalid + const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + sensor->publish_state(temperature); + return ec; + }; + + const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1); + const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT); + + if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read some sensors")); + } else if (this->emissivity_write_failed_) { + this->status_set_warning(LOG_STR("Failed to write emissivity")); + } else if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } else { + this->status_clear_warning(); } - uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f; - float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f; - - ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient); - - if (this->ambient_sensor_ != nullptr && !std::isnan(ambient)) - this->ambient_sensor_->publish_state(ambient); - if (this->object_sensor_ != nullptr && !std::isnan(object)) - this->object_sensor_->publish_state(object); - this->status_clear_warning(); } } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 882ee45186a..758792acede 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { void set_emissivity(float emissivity) { emissivity_ = emissivity; } protected: + void try_write_emissivity_(); bool write_emissivity_(); - bool write_bytes_(uint8_t reg, uint16_t data); + bool write_register_(uint8_t reg, uint16_t data); + i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data); sensor::Sensor *ambient_sensor_{nullptr}; sensor::Sensor *object_sensor_{nullptr}; float emissivity_{NAN}; + // Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear + uint8_t emissivity_write_attempts_{0}; + bool emissivity_write_failed_{false}; }; } // namespace esphome::mlx90614 From 54706e869c13abc0f688a91c0f326c779799b858 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:15 +0200 Subject: [PATCH 078/759] [deep_sleep] disable loop (#18962) --- esphome/components/deep_sleep/deep_sleep_bk72xx.cpp | 2 +- esphome/components/deep_sleep/deep_sleep_component.cpp | 3 ++- esphome/components/deep_sleep/deep_sleep_component.h | 5 +++++ esphome/components/deep_sleep/deep_sleep_esp32.cpp | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 2c97dc32114..a955095875b 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 9a3e537e051..d33102bf4fa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -17,6 +17,7 @@ void DeepSleepComponent::setup() { void DeepSleepComponent::schedule_sleep_() { this->next_enter_deep_sleep_ = false; + this->disable_loop(); const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -45,7 +46,7 @@ void DeepSleepComponent::loop() { void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 208f88d7074..0bbca4c5c4d 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component { void schedule_sleep_(); bool should_teardown_(); + void defer_sleep_() { + this->next_enter_deep_sleep_ = true; + this->enable_loop(); + } + #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 3fa1a1f1edf..20297028b2c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGW(TAG, "Waiting for wakeup pin state change"); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } return true; From a88ec7d90b6b19813f2e06bb012c7192b04b3c73 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:07:09 +0200 Subject: [PATCH 079/759] [logger] Flush uart before sleep in idf 6 (#18975) --- esphome/components/logger/logger_esp32.cpp | 13 +++++++++++-- sdkconfig.defaults | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index c3d777299d9..8579708559d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -3,6 +3,7 @@ #include "esphome/components/esp32/crash_handler.h" #include +#include #include #include @@ -16,8 +17,10 @@ #include #endif #endif - -#include "esp_idf_version.h" +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) +#include "esp_sleep.h" +#endif #include "freertos/FreeRTOS.h" #include @@ -87,6 +90,12 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1; uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0); +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) + // Always flush before going to light sleep. Could be disabled for devices + // without TOP_PD or if source_clk = UART_SCLK_RTC + esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART); +#endif } void Logger::pre_setup() { diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 2bd702f48e5..f4fe331df45 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -17,6 +17,8 @@ CONFIG_ESP_TASK_WDT_INIT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n +CONFIG_FREERTOS_USE_TICKLESS_IDLE=y +CONFIG_PM_ENABLE=y # esp32_ble CONFIG_BT_ENABLED=y From 66f829c760358a291a9a97d90f9b981d8ac6a6ec Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:08:07 +0200 Subject: [PATCH 080/759] [zigbee] wake loop on defer/set_timeout (#19050) --- esphome/components/zigbee/time/zigbee_time_zephyr.cpp | 2 ++ esphome/components/zigbee/zigbee_esp32.cpp | 5 ++++- esphome/components/zigbee/zigbee_zephyr.cpp | 6 ++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 92d238629a1..3f14d0a62d2 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include "esphome/core/application.h" namespace esphome::zigbee { @@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); + App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index cd094306f43..4f9c70da75c 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout("zb_init", 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); + App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } + App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index b8bb0a20369..286c83b8f56 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,10 +1,10 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" +#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" -#include "esphome/core/wake.h" extern "C" { #include @@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - esphome::wake_loop_threadsafe(); + App.wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { @@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); + App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); + App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT From 99241483026d54d47f8f06bdd2415e02c0cd3ebb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:59:23 +0000 Subject: [PATCH 081/759] Bump aioesphomeapi from 46.3.0 to 46.4.0 (#19071) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dfddbed00bf..72c42dad32f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.3.0 +aioesphomeapi==46.4.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 280fac11e6a8b571f6859dc4f9203e470cbbf1d4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 10 Sep 2026 10:03:33 -0500 Subject: [PATCH 082/759] [serial_proxy] Add tap interface and port mode (#18955) Co-authored-by: puddly <32534428+puddly@users.noreply.github.com> --- esphome/components/api/api.proto | 37 +++- esphome/components/api/api_connection.cpp | 16 +- esphome/components/api/api_connection.h | 1 + esphome/components/api/api_pb2.cpp | 13 ++ esphome/components/api/api_pb2.h | 21 ++ esphome/components/api/api_pb2_dump.cpp | 18 ++ esphome/components/api/api_pb2_service.cpp | 11 ++ esphome/components/api/api_pb2_service.h | 3 + esphome/components/serial_proxy/__init__.py | 1 + .../components/serial_proxy/serial_proxy.cpp | 182 +++++++++++++++--- .../components/serial_proxy/serial_proxy.h | 103 +++++++++- esphome/core/defines.h | 1 + .../components/serial_proxy/serial_proxy.h | 3 + .../serial_proxy/test-tap.esp32-idf.yaml | 14 ++ 14 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 tests/components/serial_proxy/test-tap.esp32-idf.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3a0e0abea96..21972decad1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -77,6 +77,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} } @@ -2726,7 +2727,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2752,7 +2754,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2763,7 +2766,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2802,6 +2806,7 @@ enum SerialProxyRequestType { // error the device answers with INVALID_ARGUMENT. SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { @@ -2814,7 +2819,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } -// Generic request message for simple serial proxy operations +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; @@ -2838,6 +2844,29 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. +message SerialProxySetModeRequest { + option (id) = 152; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702f..d910f6fc67a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1661,6 +1661,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: // Response-only discriminators; never valid in a request ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; @@ -1673,6 +1674,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { send_serial_proxy_ack(this, msg.instance, msg.type, status); } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); + return; + } + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { if (!this->send_message(msg)) { ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); @@ -1799,7 +1813,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 16; + resp.api_version_minor = 17; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a4c49dccf40..c19a33ca9bb 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -244,6 +244,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2de1f0a15ce..7f162d9c159 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4253,6 +4253,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->error_message.size()); return size; } +bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + case 2: + this->mode = static_cast(value); + break; + default: + return false; + } + return true; +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c3429a63aa..799aaa27b55 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_PORT_IN_USE = 5, SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, +}; #endif } // namespace enums @@ -3403,6 +3408,22 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 152; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee307..bb244973a11 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -2805,6 +2817,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 65c7b8858cc..172062be636 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_device_capabilities_request(); break; } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } +#endif default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6abdf7093e1..a4dfd6a3663 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -235,6 +235,9 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 4186fcf8b13..b6e780fabdc 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -30,6 +30,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index c1c15106438..129745c1c91 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -29,26 +29,57 @@ void SerialProxy::setup() { #ifdef USE_API // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; +#endif +#ifdef USE_SERIAL_PROXY_TAP + // A tap sets itself up before this runs (its setup priority is higher), so it may + // already be waiting on the port -- a boot-time handshake with the device, say. Leaving + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } -void SerialProxy::loop() { -#ifdef USE_API - // Safety check — loop should only run when subscribed, but guard against races - if (this->api_connection_ == nullptr) [[unlikely]] { - this->disable_loop(); +#ifdef USE_SERIAL_PROXY_TAP +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { return; } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} +#endif +void SerialProxy::loop() { +#ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected()) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->reset_mode_(); + } + + // With no subscriber there is normally nothing to do, but a tap may still need the port + // read -- it does its protocol work precisely while nobody else is listening. + if (this->api_connection_ == nullptr) [[unlikely]] { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + return; + } +#else this->disable_loop(); return; +#endif } // Read available data from UART and forward to subscribed client @@ -69,11 +100,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { if (!this->read_array(buffer, to_read)) return; +#ifdef USE_SERIAL_PROXY_TAP + // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not + // waiting on the network round trip to a subscriber that may not even exist. + if (this->tap_observing_()) { + this->tap_->on_device_rx(buffer, to_read); + } +#endif + + if (this->api_connection_ == nullptr) { + return; + } this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); } #endif +#ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // With no subscriber, a tap doing its own protocol work (the boot-time handshake with + // the device, say) is served regardless of mode -- nobody has chosen one yet. Once a + // subscriber holds the port, the mode alone decides, so RAW stays inert. + if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL; +} + +void SerialProxy::tap_pump() { +#ifdef USE_API + // Nothing would consume the bytes; leave them in the FIFO + if (!this->tap_observing_() && this->api_connection_ == nullptr) { + return; + } + const size_t available = this->available(); + if (available > 0) { + this->read_and_send_(available); + } +#endif +} +#endif + void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, "Serial Proxy [%" PRIu32 "]:\n" @@ -92,8 +166,9 @@ void SerialProxy::dump_config() { SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -159,24 +234,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection, + api::enums::SerialProxyMode mode) { +#ifdef USE_API + // Only the live subscriber may change the mode, so the mode cannot outlive a session + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; + } +#endif + // Values come from a remote client + if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) { + ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); + return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; + } + // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows +#ifdef USE_SERIAL_PROXY_TAP + const bool has_tap = this->tap_ != nullptr; +#else + const bool has_tap = false; +#endif + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; + } + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); +#ifdef USE_SERIAL_PROXY_TAP + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API - // Bytes from a client other than the live subscriber would interleave with the - // subscriber's traffic on the wire - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + // Bytes from anyone but the live subscriber would interleave with the subscriber's + // traffic -- or with an active tap's -- on the wire + if (!this->is_subscriber_(api_connection)) { + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_); + } else { + // A legacy client streaming writes without subscribing would flood WARN, one per + // request; writes are the only high-rate, unacknowledged operation, so keep this + // visible without drowning the log + ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + } return; } #endif if (data == nullptr || len == 0) return; this->write_array(data, len); + +#ifdef USE_SERIAL_PROXY_TAP + // After the write, so the tap observes the same ordering the device does + if (this->tap_observing_()) { + this->tap_->on_client_tx(data, len); + } +#endif } SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -210,8 +341,8 @@ uint32_t SerialProxy::get_modem_pins() const { SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { #ifdef USE_API // Flushing stalls the port, so it gets the same ownership check as writes - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -230,11 +361,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { } #ifdef USE_API -bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { - return this->api_connection_ != nullptr && this->api_connection_ != api_connection && - this->api_connection_->is_connection_setup(); -} - SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { @@ -252,6 +378,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + // End the dead client's session before starting the new one, so its mode + // cannot leak into a session that never asked for it + this->api_connection_ = nullptr; + this->reset_mode_(); } this->api_connection_ = api_connection; this->enable_loop(); @@ -264,7 +394,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } this->api_connection_ = nullptr; + this->reset_mode_(); +#ifdef USE_SERIAL_PROXY_TAP + // Keep the loop alive for a tap that still needs the port (mirrors loop()) + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + } +#else this->disable_loop(); +#endif ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a0e47ee6864..e3f4264cfa5 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -26,6 +26,7 @@ class APIConnection; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -52,6 +53,36 @@ enum class SerialProxyResult : uint8_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; +#ifdef USE_SERIAL_PROXY_TAP +/// Observes a port's traffic without owning it, and may inject bytes of its own. +/// +/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without +/// the pipe knowing anything about the protocol: the tap is compiled in only when some +/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it. +/// +/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes +/// flowing in either direction, so a misbehaving tap cannot corrupt the stream. +class SerialProxyTap { + public: + /// Bytes read from the device, before they are forwarded to any subscriber. + virtual void on_device_rx(const uint8_t *data, size_t len) = 0; + + /// Bytes a subscriber sent towards the device, after they have been written. + virtual void on_client_tx(const uint8_t *data, size_t len) = 0; + + /// True when the port must keep reading even with no subscriber attached, so a tap can + /// do its own protocol work while nobody is listening. Honoured only while no + /// subscriber holds the port; with one attached, the port mode alone decides. + virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// else with the device -- reflash it, most likely -- so anything the tap believes about + /// it should be treated as suspect. + virtual void on_protocol_disabled() = 0; +}; +#endif + class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; @@ -77,6 +108,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Handle a mode change requested by an API client + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -121,13 +155,67 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_TAP + /// Attach a traffic observer. At most one, set once at setup time. + void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } + + /// Write bytes originating from the tap rather than from a client. Bypasses the + /// subscriber ownership check, but only while the tap is being served bytes -- so a + /// port in RAW mode with a subscriber attached stays inert. Returns false when the + /// bytes were dropped for that reason. + bool write_from_tap(const uint8_t *data, size_t len) { + if (!this->tap_observing_()) { + return false; + } + this->write_array(data, len); + return true; + } + + /// Whether the tap is currently being served bytes. Can flip false with no callback + /// (a subscriber attaching in RAW mode, say), so a tap should check before starting + /// protocol work and when a reply seems overdue. + bool tap_is_observed() const { return this->tap_observing_(); } + + /// Resume reading after a tap's needs change. loop() disables itself when there is + /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work + /// must ask for it back. Must be called from the main loop. + void tap_request_port() { this->enable_loop(); } + + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the + /// main loop is running -- during setup, for instance, while a component is still + /// blocking on can_proceed(). Must not be called from on_device_rx() or + /// on_client_tx(): each nested cycle costs a 256-byte stack frame. + void tap_pump(); +#endif + protected: #ifdef USE_API - /// Read from UART and send to API client (slow path with 256-byte stack buffer) + /// Read from UART, hand the bytes to any tap, and forward them to a subscriber + /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); - /// True when a live subscriber other than the given connection holds the port - bool port_claimed_by_other_(api::APIConnection *api_connection) const; + /// True when the given connection is the live subscriber. Every port operation + /// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed + /// client can never share the wire with the subscriber or an active tap. + bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// Return the port to RAW when a subscriber goes away, so the mode never outlives it + void reset_mode_(); +#else + /// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is + /// nothing to reset + void reset_mode_() {} +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; #endif /// Instance index for identifying this proxy in API messages @@ -147,6 +235,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; +#ifdef USE_SERIAL_PROXY_TAP + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; +#endif + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; @@ -154,6 +247,10 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Current modem pin states bool rts_state_{false}; bool dtr_state_{false}; + +#ifdef USE_SERIAL_PROXY_TAP + SerialProxyTap *tap_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eaece6d5ffa..c3b16d833a4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -181,6 +181,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h index 6fc20f33508..7da6fff017b 100644 --- a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -40,6 +40,9 @@ class SerialProxy { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {} + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) { + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; + } SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } diff --git a/tests/components/serial_proxy/test-tap.esp32-idf.yaml b/tests/components/serial_proxy/test-tap.esp32-idf.yaml new file mode 100644 index 00000000000..5522e53c476 --- /dev/null +++ b/tests/components/serial_proxy/test-tap.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +# Compile the tap code paths; no tap is attached, so this exercises the +# null-tap branches that a normal build never defines. +esphome: + platformio_options: + build_flags: + - "-DUSE_SERIAL_PROXY_TAP" + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + serial_proxy: !include common.yaml From a807a8f9451b172abf4cb05ca2f35c609224e414 Mon Sep 17 00:00:00 2001 From: matt123p Date: Thu, 10 Sep 2026 16:11:43 +0100 Subject: [PATCH 083/759] [es7210] Fix 4 channel microphone support (#19034) --- esphome/components/es7210/es7210.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 892b67b270a..5afc22aec4f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); // Configure mic 3 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); // Configure mic 4 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); From 7564f5ff1ace9bbe107ec79107bd6606796f3156 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 10 Sep 2026 12:06:58 -0400 Subject: [PATCH 084/759] [sendspin] Add manufacturer, model, and firmware version options (#18792) Co-authored-by: J. Nick Koston --- esphome/components/sendspin/__init__.py | 32 +++++++ esphome/components/sendspin/sendspin_hub.cpp | 16 +++- esphome/components/sendspin/sendspin_hub.h | 19 +++++ .../sendspin/config/device_info_default.yaml | 12 +++ .../sendspin/config/device_info_explicit.yaml | 18 ++++ .../sendspin/config/device_info_project.yaml | 15 ++++ .../sendspin/test_device_info.py | 83 +++++++++++++++++++ tests/components/sendspin/common-hub.yaml | 3 + 8 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/sendspin/config/device_info_default.yaml create mode 100644 tests/component_tests/sendspin/config/device_info_explicit.yaml create mode 100644 tests/component_tests/sendspin/config/device_info_project.yaml create mode 100644 tests/component_tests/sendspin/test_device_info.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c1970ab1325..c21047c70a7 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -6,12 +6,17 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_ESPHOME, CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MODEL, + CONF_NAME, + CONF_PROJECT, CONF_SAMPLE_RATE, CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_VERSION, CONF_WIDTH, ) from esphome.core import CORE, ID @@ -27,6 +32,14 @@ DOMAIN = "sendspin" CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" +CONF_FIRMWARE_VERSION = "firmware_version" +CONF_MANUFACTURER = "manufacturer" + +# An empty device information string would be sent to the server as an empty value rather than +# falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps +# the length prefix of a protobuf string field to a single byte, matching `esphome: project:`. +DEVICE_INFO_STRING = cv.All(cv.string_strict, cv.Length(min=1), cv.ByteLength(max=127)) + CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" @@ -198,6 +211,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SendspinHub), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, + cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, + cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, + cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, } ), cv.only_on_esp32, @@ -248,6 +264,22 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + # Device information for the server's client/hello message. Falls back to the project + # information, which is written as `manufacturer.model`. Anything still unset keeps the + # default the hub itself applies: the ESPHome name and version. + project = CORE.config[CONF_ESPHOME].get(CONF_PROJECT, {}) + project_manufacturer, _, project_model = project.get(CONF_NAME, "").partition(".") + for value, setter in ( + (config.get(CONF_MANUFACTURER) or project_manufacturer, var.set_manufacturer), + (config.get(CONF_MODEL) or project_model, var.set_model), + ( + config.get(CONF_FIRMWARE_VERSION) or project.get(CONF_VERSION), + var.set_firmware_version, + ), + ): + if value: + cg.add(setter(value)) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 028491284a7..2cb2b909951 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -76,8 +76,12 @@ void SendspinHub::dump_config() { ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " Manufacturer: %s\n" + " Model: %s\n" + " Firmware version: %s\n" " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_)); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -127,15 +131,19 @@ const char *SendspinHub::get_client_id_into_buffer(std::spanmodel_ != nullptr ? this->model_ : App.get_name().c_str(); +} + sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); config.name = App.get_friendly_name(); - config.product_name = App.get_name(); - config.manufacturer = "ESPHome"; - config.software_version = ESPHOME_VERSION; + config.product_name = this->get_product_name_(); + config.manufacturer = this->manufacturer_; + config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; return config; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c50c3eb809..c66c7db3ccb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/version.h" #include #include @@ -125,6 +126,15 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Sets the device information reported to the server in the `client/hello` message. + /// + /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the + /// lifetime of the hub. Only called for values the configuration overrides; anything left alone + /// keeps the default described on the member below. + void set_manufacturer(const char *manufacturer) { this->manufacturer_ = manufacturer; } + void set_model(const char *model) { this->model_ = model; } + void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -187,6 +197,9 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// @brief Returns the product name reported to the server: the configured model, or the device name. + const char *get_product_name_() const; + /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); @@ -268,6 +281,12 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; + + // Device information sent in the `client/hello` message. Defaults apply when neither the + // sendspin configuration nor the project information supplies a value. + const char *manufacturer_{"ESPHome"}; + const char *model_{nullptr}; // nullptr reports the device name instead + const char *firmware_version_{ESPHOME_VERSION}; }; /// @brief Base class for all sendspin subcomponents. diff --git a/tests/component_tests/sendspin/config/device_info_default.yaml b/tests/component_tests/sendspin/config/device_info_default.yaml new file mode 100644 index 00000000000..669b2e99bc6 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_default.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/config/device_info_explicit.yaml b/tests/component_tests/sendspin/config/device_info_explicit.yaml new file mode 100644 index 00000000000..c3fec3ead45 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_explicit.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: + manufacturer: Explicit Manufacturer + model: Explicit Model + firmware_version: 1.2.3 diff --git a/tests/component_tests/sendspin/config/device_info_project.yaml b/tests/component_tests/sendspin/config/device_info_project.yaml new file mode 100644 index 00000000000..395b2889fc3 --- /dev/null +++ b/tests/component_tests/sendspin/config/device_info_project.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + project: + name: project_manufacturer.project_model + version: 9.9.9 + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +sendspin: diff --git a/tests/component_tests/sendspin/test_device_info.py b/tests/component_tests/sendspin/test_device_info.py new file mode 100644 index 00000000000..833dd398b41 --- /dev/null +++ b/tests/component_tests/sendspin/test_device_info.py @@ -0,0 +1,83 @@ +"""Tests for the device information the sendspin hub reports to the server.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import ( + CONF_FIRMWARE_VERSION, + CONF_MANUFACTURER, + CONFIG_SCHEMA, +) +from esphome.const import CONF_MODEL, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_explicit_device_info_wins_over_project( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Configured values take precedence over the project information.""" + main_cpp = generate_main(component_config_path("device_info_explicit.yaml")) + + assert 'set_manufacturer("Explicit Manufacturer")' in main_cpp + assert 'set_model("Explicit Model")' in main_cpp + assert 'set_firmware_version("1.2.3")' in main_cpp + + +def test_project_supplies_device_info( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without configured values, the project name splits into manufacturer and model.""" + main_cpp = generate_main(component_config_path("device_info_project.yaml")) + + assert 'set_manufacturer("project_manufacturer")' in main_cpp + assert 'set_model("project_model")' in main_cpp + assert 'set_firmware_version("9.9.9")' in main_cpp + + +def test_no_device_info_leaves_hub_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With neither source, nothing is emitted and the hub keeps its own defaults.""" + main_cpp = generate_main(component_config_path("device_info_default.yaml")) + + assert "set_manufacturer(" not in main_cpp + assert "set_model(" not in main_cpp + assert "set_firmware_version(" not in main_cpp + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_empty_device_info_rejected( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """An empty string would be sent to the server as an empty value, so it is not accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: ""}) + + +@pytest.mark.parametrize( + "conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION] +) +def test_device_info_capped_at_127_bytes( + set_core_config: SetCoreConfigCallable, conf_key: str +) -> None: + """The cap is in bytes so the protobuf length prefix stays a single byte.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA({conf_key: "a" * 127}) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "a" * 128}) + # 64 two-byte characters is 128 bytes. + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({conf_key: "é" * 64}) diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 7a6a9ffd4f3..bd6747ee07b 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -4,3 +4,6 @@ psram: sendspin: id: sendspin_hub_id task_stack_in_psram: true + manufacturer: Test Manufacturer + model: Test Model + firmware_version: 1.2.3 From 3e3822e5541f3562fae64b29837b79b1027af674 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:30 +0000 Subject: [PATCH 085/759] Bump bundled esphome-device-builder to 1.14.6 (#19072) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ac84ee4689f..cfa47fbdad2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 RUN \ platformio settings set enable_telemetry No \ From f66ef23256f467a572517f6ee87956f5f527fa60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 03:20:30 -0500 Subject: [PATCH 086/759] [core] Support set_internal() during setup, log error after setup (#19069) --- esphome/core/entity_base.cpp | 9 ++++ esphome/core/entity_base.h | 27 ++++++++---- .../fixtures/set_internal_at_boot.yaml | 34 +++++++++++++++ .../integration/test_set_internal_at_boot.py | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/set_internal_at_boot.yaml create mode 100644 tests/integration/test_set_internal_at_boot.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706c..dc27c1e56a2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52d..8796e9f067a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 00000000000..b3007e9dbda --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 00000000000..68b0bd10802 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} From 380938177c1cc0599f4df97f1368adb96f48f07a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 11 Sep 2026 03:25:52 -0500 Subject: [PATCH 087/759] [uart] Add apply_settings_live() for in-place ESP-IDF reconfiguration (#19087) Co-authored-by: Claude Fable 5.1 --- .../uart/uart_component_esp_idf.cpp | 129 +++++++++++++----- .../components/uart/uart_component_esp_idf.h | 36 +++++ 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index bbeb86bcdb7..e5d5fbc9839 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -160,6 +160,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } + this->last_good_framing_ = this->framing_(); int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; @@ -189,18 +190,9 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - uint32_t invert = 0; - if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { - invert |= UART_SIGNAL_TXD_INV; - } - if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { - invert |= UART_SIGNAL_RXD_INV; - } - if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { - invert |= UART_SIGNAL_RTS_INV; - } - - err = uart_set_line_inverse(this->uart_num_, invert); + // Must precede uart_set_pin() so an inverted TX line never shows the wrong idle + // level; apply_line_settings_() repeats it later for the reset registers. + err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); this->mark_failed(); @@ -214,25 +206,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). - auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + if (this->apply_line_settings_() != ESP_OK) { this->mark_failed(); return; } @@ -250,6 +224,99 @@ void IDFUARTComponent::load_settings(bool dump_config) { } } +uint32_t IDFUARTComponent::line_inversion_mask_() { + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + return invert; +} + +esp_err_t IDFUARTComponent::apply_line_settings_() { + // uart_param_config() resets these; call after every use of it. + esp_err_t err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return err; + } + + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +void IDFUARTComponent::set_framing_(const Framing &framing) { + this->baud_rate_ = framing.baud_rate; + this->data_bits_ = framing.data_bits; + this->stop_bits_ = framing.stop_bits; + this->parity_ = framing.parity; + this->rx_full_threshold_ = framing.rx_full_threshold; +} + +esp_err_t IDFUARTComponent::apply_settings_live() { + if (this->is_failed()) { + return ESP_ERR_INVALID_STATE; + } + // No driver yet: nothing to reconfigure in place. + if (!uart_is_driver_installed(this->uart_num_)) { + this->load_settings(false); + return this->is_failed() ? ESP_FAIL : ESP_OK; + } + // Keeps the driver ring buffers; flushes both hardware FIFOs (in-flight bytes lost). + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + // Failure leaves the registers reset; put back the last accepted framing so the + // getters still describe the hardware. + if (this->last_good_framing_.baud_rate == 0) { + ESP_LOGE(TAG, "uart_param_config (live) failed: %s; no previous framing to restore", esp_err_to_name(err)); + this->mark_failed(); + return err; + } + ESP_LOGW(TAG, "uart_param_config (live) failed: %s; restoring %" PRIu32 " baud", esp_err_to_name(err), + this->last_good_framing_.baud_rate); + this->set_framing_(this->last_good_framing_); + uart_config = this->get_config_(); + esp_err_t restore_err = uart_param_config(this->uart_num_, &uart_config); + if (restore_err != ESP_OK) { + ESP_LOGE(TAG, "UART left unconfigured after failed live reconfigure: %s", esp_err_to_name(restore_err)); + this->mark_failed(); + return err; + } + // Previous framing is live again; report the refusal (line-setting errors log). + this->apply_line_settings_(); + return err; + } + this->last_good_framing_ = this->framing_(); + // The new framing is live; a line-setting failure here only logs. + this->apply_line_settings_(); + return ESP_OK; +} + void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); LOG_PIN(" TX Pin: ", this->tx_pin_); diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index a761d80f04a..d9297bfa34a 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -52,13 +52,49 @@ class IDFUARTComponent final : public UARTComponent, public Component { void load_settings(bool dump_config) override; using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + /** + * Apply the current framing (baud rate, parity, data/stop bits) to the installed + * driver in place, without the delete/reinstall of load_settings(). Tasks blocked in + * the driver survive and the ring buffers are kept, but both hardware FIFOs are + * flushed: a frame in flight reaches the peer truncated and bytes not yet out of the + * RX FIFO are dropped. No lock is taken: quiesce writers first if that matters. + * rx_full_threshold is not rescaled (call set_rx_full_threshold_ms() first if it + * should follow the baud rate); a rollback restores the value from the last accepted + * configuration, undoing a standalone set_rx_full_threshold() made since. Without an + * installed driver this is a full load_settings(false) instead. + * + * @return ESP_OK once the new framing is live (a line-setting error after that only + * logs). On rejection (unreachable baud rate) the previous framing is restored and + * the driver's error returned; if the restore fails too the component is marked + * failed. ESP_ERR_INVALID_STATE if already failed; ESP_FAIL if the fallback + * load_settings() fails. + */ + esp_err_t apply_settings_live(); + void on_shutdown() override; protected: void check_logger_conflict() override; + uint32_t line_inversion_mask_(); + // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. + esp_err_t apply_line_settings_(); uart_port_t uart_num_{UART_NUM_MAX}; uart_config_t get_config_(); + struct Framing { + uint32_t baud_rate; + uint8_t data_bits; + uint8_t stop_bits; + UARTParityOptions parity; + size_t rx_full_threshold; // sized for the baud rate, so rolled back with it + }; + Framing framing_() const { + return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_}; + } + void set_framing_(const Framing &framing); + // Last framing the driver accepted; baud_rate 0 means none yet. + Framing last_good_framing_{}; + bool has_peek_{false}; uint8_t peek_byte_; uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). From 6a21ab4ea705cb4c885cb2590683949874d9b931 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:30:51 +1200 Subject: [PATCH 088/759] [esp32] Trim mbedTLS to client-only defaults and stub vasprintf on the C6 (#19088) --- esphome/components/esp32/__init__.py | 118 ++++++++++++++++++ esphome/components/esp32/vasprintf_stubs.cpp | 53 ++++++++ esphome/components/openthread/__init__.py | 10 ++ esphome/components/wifi/__init__.py | 7 ++ esphome/core/defines.h | 1 + .../esp32/config/mbedtls_tls_default.yaml | 14 +++ .../esp32/config/mbedtls_tls_openthread.yaml | 19 +++ .../esp32/config/mbedtls_tls_opt_out.yaml | 17 +++ .../config/mbedtls_tls_user_sdkconfig.yaml | 17 +++ .../esp32/config/mbedtls_tls_wifi_eap.yaml | 17 +++ .../esp32/config/vasprintf_stub_c6.yaml | 7 ++ .../config/vasprintf_stub_c6_full_printf.yaml | 9 ++ tests/component_tests/esp32/test_esp32.py | 99 +++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 2 + .../http_request/test.esp32-c6-idf.yaml | 4 + 15 files changed, 394 insertions(+) create mode 100644 esphome/components/esp32/vasprintf_stubs.cpp create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_default.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml create mode 100644 tests/component_tests/esp32/config/vasprintf_stub_c6.yaml create mode 100644 tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml create mode 100644 tests/components/http_request/test.esp32-c6-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3f5a34bc735..d027c9a1c61 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -189,6 +189,13 @@ PSRAM_XIP_VARIANTS = { VARIANT_ESP32S31, } +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral @@ -1732,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" +CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1746,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" +KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" @@ -1830,6 +1841,30 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_tls_server() -> None: + """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + + Call this from components that accept TLS connections (OpenThread's DTLS + commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from + being selected. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + + +def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: + """Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required. + + ``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep; + omit it to keep all of them. Call this from components that need AES-CCM, + deterministic ECDSA signing, static RSA/ECDH key exchange, TLS + renegotiation or session tickets, or that run a TLS client against + servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied + sdkconfig_options value is never overridden either. + """ + required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + + def require_mbedtls_sha512() -> None: """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. @@ -1987,6 +2022,8 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -2302,6 +2339,69 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None: set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# deterministic ECDSA only matters when signing with a private key. Together +# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt). +# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS +# servers ESPHome cannot vet, and a failed EAP handshake leaves the device +# off the network, so the wifi component re-enables all of these when eap is +# configured. +# The EC public key parsing extras stay enabled: they decide whether a peer +# certificate with a compressed point or explicit curve parameters parses, +# which no component can know ahead of time. +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + +# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one +# member is only valid when the user has not already chosen another. +MBEDTLS_TLS_ROLE_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_DISABLED", +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_tls_sdkconfig( + disable_tls_server: bool, disable_tls_extras: bool +) -> None: + """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. + + Runs at FINAL priority so every require_mbedtls_tls_server() and + require_mbedtls_tls_extras() call has happened. Only the server-side + handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS + connections, but OpenThread's DTLS commissioner does. A user-supplied + sdkconfig_options value always wins; for the TLS role choice, any member + the user set leaves the whole choice alone so the pair cannot conflict. + """ + data = CORE.data[KEY_ESP32] + sdkconfig = data[KEY_SDKCONFIG_OPTIONS] + if ( + disable_tls_server + and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) + and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) + if disable_tls_extras: + required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + for option in MBEDTLS_TLS_EXTRA_OPTIONS: + if option not in required: + set_idf_sdkconfig_default(option, False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2566,6 +2666,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2991,6 +3102,13 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) + # FINAL priority: runs after every require_mbedtls_tls_*() call + CORE.add_job( + _reconcile_mbedtls_tls_sdkconfig, + advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], + advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], + ) + # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. CORE.add_job( diff --git a/esphome/components/esp32/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 00000000000..308a58ebda7 --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,53 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + // vasprintf's contract is a malloc'd buffer the caller releases with free() + char *buf = static_cast(malloc(static_cast(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc) + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index ab69f5d9ae5..a71151f3ffb 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,6 +13,8 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_tls_extras, + require_mbedtls_tls_server, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -109,6 +111,14 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + # OpenThread's DTLS commissioner is a TLS server, and its crypto platform + # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component + # from trimming them out of mbedTLS. + require_mbedtls_tls_server() + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1691dcc2935..58803a8cdff 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls_extras, ) from esphome.components.network import ( add_use_address, @@ -658,6 +659,12 @@ async def to_code(config): # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) + if has_eap: + # wpa_supplicant's EAP client negotiates with whatever the RADIUS + # server offers, and a failed handshake leaves the device off the + # network, so keep every mbedTLS client feature the esp32 platform + # would otherwise trim. + require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP if has_manual_ip: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c3b16d833a4..9144e65576f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_VASPRINTF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER diff --git a/tests/component_tests/esp32/config/mbedtls_tls_default.yaml b/tests/component_tests/esp32/config/mbedtls_tls_default.yaml new file mode 100644 index 00000000000..b29e5de2bd9 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_default.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: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml new file mode 100644 index 00000000000..62ca893d2c5 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml new file mode 100644 index 00000000000..e675848391b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_opt_out.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + disable_mbedtls_tls_server: false + disable_mbedtls_tls_extras: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml new file mode 100644 index 00000000000..44ff047a489 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_user_sdkconfig.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + sdkconfig_options: + CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT: y + CONFIG_MBEDTLS_CCM_C: y + +wifi: + ssid: "test_ssid" + password: "test_password" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml new file mode 100644 index 00000000000..6c78e062650 --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_wifi_eap.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + eap: + identity: "user@example.org" + username: "user" + password: "secret" + +http_request: + verify_ssl: true diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml new file mode 100644 index 00000000000..8fa28e7c0f4 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml new file mode 100644 index 00000000000..075c3913b53 --- /dev/null +++ b/tests/component_tests/esp32/config/vasprintf_stub_c6_full_printf.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + enable_full_printf: true diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 759020c732d..2dd2a50c83c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -11,9 +11,12 @@ import pytest from esphome.components.esp32 import ( KEY_FATFS_REQUIRED, + KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, + KEY_MBEDTLS_TLS_SERVER_REQUIRED, KEY_VFS_DIR_REQUIRED, KEY_VFS_SELECT_REQUIRED, KEY_VFS_TERMIOS_REQUIRED, + MBEDTLS_TLS_EXTRA_OPTIONS, VARIANT_ESP32, VARIANTS, NetworkSdkconfigData, @@ -1339,3 +1342,99 @@ def test_esp32_s31_gpio_validation( with caplog.at_level("WARNING"): validate_supports(pin) assert "GPIO36 is a strapping PIN" in caplog.text + + +_TLS_SERVER_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", +) + + +@pytest.mark.parametrize( + ("config_file", "server", "extras"), + [ + pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"), + pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"), + pytest.param("mbedtls_tls_wifi_eap.yaml", (True, False), None, id="wifi_eap"), + ], +) +def test_mbedtls_tls_trim_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + server: tuple[bool | None, bool | None], + extras: bool | None, +) -> None: + """Client-only TLS and the unused-feature trims apply unless opted out or required.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server + assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} + + +_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} + + +def test_mbedtls_tls_openthread_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False) + + +def test_mbedtls_tls_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A user-set TLS role member leaves the whole choice alone; other user values are kept.""" + generate_main(component_config_path("mbedtls_tls_user_sdkconfig.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is None + role = sdkconfig["CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT"] + assert isinstance(role, RawSdkconfigValue) and role.value == "y" + ccm = sdkconfig["CONFIG_MBEDTLS_CCM_C"] + assert isinstance(ccm, RawSdkconfigValue) and ccm.value == "y" + assert { + sdkconfig.get(name) + for name in MBEDTLS_TLS_EXTRA_OPTIONS + if name != "CONFIG_MBEDTLS_CCM_C" + } == {False} + + +def test_mbedtls_tls_openthread_requires_server_and_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS + + +_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + pytest.param("vasprintf_stub_c6.yaml", True, id="c6"), + pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"), + pytest.param("exclusion_reincludes.yaml", False, id="esp32"), + ], +) +def test_vasprintf_stub_only_on_rom_vsnprintf_variants( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf.""" + generate_main(component_config_path(config_file)) + assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 523e614e240..7f31fe59c6f 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -17,6 +17,8 @@ esp32: disable_dev_null_vfs: true disable_mbedtls_peer_cert: true disable_mbedtls_pkcs7: true + disable_mbedtls_tls_server: true + disable_mbedtls_tls_extras: true disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true diff --git a/tests/components/http_request/test.esp32-c6-idf.yaml b/tests/components/http_request/test.esp32-c6-idf.yaml new file mode 100644 index 00000000000..ee2f5aa59b8 --- /dev/null +++ b/tests/components/http_request/test.esp32-c6-idf.yaml @@ -0,0 +1,4 @@ +substitutions: + verify_ssl: "true" + +<<: !include common.yaml From 37e9b2b7af3ae84358bf59c9270462d551ec7644 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 14:12:40 -0500 Subject: [PATCH 089/759] [remote_base] Make protocol methods non-virtual and size receiver lists from codegen (#19084) --- AGENTS.md | 3 + esphome/components/coolix/climate.py | 3 +- esphome/components/infrared/infrared.cpp | 5 - esphome/components/infrared/infrared.h | 3 +- esphome/components/ir_rf_proxy/infrared.py | 10 +- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 4 - esphome/components/ir_rf_proxy/ir_rf_proxy.h | 3 +- .../components/ir_rf_proxy/radio_frequency.py | 10 +- esphome/components/midea/climate.py | 3 +- esphome/components/midea_ir/climate.py | 6 +- esphome/components/remote_base/__init__.py | 109 ++++++++++++++++-- .../remote_base/abbwelcome_protocol.h | 6 +- .../components/remote_base/aeha_protocol.h | 6 +- .../components/remote_base/beo4_protocol.h | 6 +- .../remote_base/brennenstuhl_protocol.h | 6 +- .../components/remote_base/byronsx_protocol.h | 6 +- .../remote_base/canalsat_protocol.h | 6 +- .../components/remote_base/coolix_protocol.h | 6 +- .../components/remote_base/dish_protocol.h | 6 +- .../components/remote_base/dooya_protocol.h | 6 +- .../components/remote_base/drayton_protocol.h | 6 +- .../components/remote_base/dyson_protocol.h | 6 +- .../components/remote_base/gobox_protocol.h | 6 +- .../components/remote_base/haier_protocol.h | 6 +- esphome/components/remote_base/jvc_protocol.h | 6 +- .../components/remote_base/keeloq_protocol.h | 6 +- esphome/components/remote_base/lg_protocol.h | 6 +- .../remote_base/magiquest_protocol.h | 6 +- .../components/remote_base/midea_protocol.h | 6 +- .../components/remote_base/mirage_protocol.h | 6 +- esphome/components/remote_base/nec_protocol.h | 6 +- .../components/remote_base/nexa_protocol.h | 6 +- .../remote_base/panasonic_protocol.h | 6 +- .../components/remote_base/pioneer_protocol.h | 6 +- .../components/remote_base/pronto_protocol.h | 6 +- esphome/components/remote_base/rc5_protocol.h | 6 +- esphome/components/remote_base/rc6_protocol.h | 6 +- .../remote_base/rc_switch_protocol.cpp | 38 +++--- .../remote_base/rc_switch_protocol.h | 35 +++++- .../components/remote_base/remote_base.cpp | 39 +++++-- esphome/components/remote_base/remote_base.h | 74 ++++++++---- .../components/remote_base/roomba_protocol.h | 6 +- .../remote_base/samsung36_protocol.h | 6 +- .../components/remote_base/samsung_protocol.h | 6 +- .../components/remote_base/sony_protocol.h | 6 +- .../remote_base/symphony_protocol.h | 6 +- .../remote_base/toshiba_ac_protocol.h | 6 +- .../components/remote_base/toto_protocol.h | 6 +- .../components/remote_receiver/__init__.py | 4 +- esphome/components/toshiba/climate.py | 3 +- esphome/core/defines.h | 37 ++++++ esphome/cpp_helpers.py | 38 ++++-- .../remote_receiver/__init__.py | 0 .../remote_receiver/config/receiver_bare.yaml | 9 ++ .../config/receiver_with_dumpers.yaml | 24 ++++ .../config/receiver_with_proxies.yaml | 22 ++++ .../remote_receiver/test_slot_counts.py | 91 +++++++++++++++ .../remote_receiver/bare-common.yaml | 6 + .../remote_receiver/test-bare.esp32-idf.yaml | 5 + tests/unit_tests/test_cpp_helpers.py | 25 ++++ 60 files changed, 606 insertions(+), 201 deletions(-) create mode 100644 tests/component_tests/remote_receiver/__init__.py create mode 100644 tests/component_tests/remote_receiver/config/receiver_bare.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml create mode 100644 tests/component_tests/remote_receiver/test_slot_counts.py create mode 100644 tests/components/remote_receiver/bare-common.yaml create mode 100644 tests/components/remote_receiver/test-bare.esp32-idf.yaml diff --git a/AGENTS.md b/AGENTS.md index 98bdd58ec52..8db3cd3d624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -629,6 +629,9 @@ file does, and it is the authority when they disagree. The most useful starting _request_listener_slot() cg.add(hub.register_listener(var)) ``` + When several instances each own a list declared at the same size (one per hub of a + `MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`; + the define is then the largest count any one key requested instead of the total. ```cpp #ifdef MY_COMPONENT_LISTENER_COUNT void register_listener(MyComponentListener *listener); diff --git a/esphome/components/coolix/climate.py b/esphome/components/coolix/climate.py index 3eb8dbe2f41..fcca8b89dba 100644 --- a/esphome/components/coolix/climate.py +++ b/esphome/components/coolix/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -12,4 +12,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate) async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("coolix") # used from C++ await climate_ir.new_climate_ir(config) diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 5a909738c6f..83039a5a9bf 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -59,11 +59,6 @@ void Infrared::setup() { // Set up traits based on configuration this->traits_.set_supports_transmitter(this->has_transmitter()); this->traits_.set_supports_receiver(this->has_receiver()); - - // Register as listener for received IR data - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void Infrared::dump_config() { diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index b6863e37ce5..afbde57be2f 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -119,7 +119,8 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 3218889721c..288bd916738 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,7 +3,12 @@ from typing import Any import esphome.codegen as cg -from esphome.components import infrared, remote_receiver, remote_transmitter +from esphome.components import ( + infrared, + remote_base, + remote_receiver, + remote_transmitter, +) from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -82,8 +87,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) if CONF_RECEIVER_FREQUENCY in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb6..ceb4c9a67c3 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -97,10 +97,6 @@ void RfProxy::setup() { // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); - - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void RfProxy::dump_config() { diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 5fc683354ba..1aa4394fe84 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -56,7 +56,8 @@ class RfProxy final : public radio_frequency::RadioFrequency { /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index a243909837f..28b8fd5953a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,7 +1,12 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import radio_frequency, remote_receiver, remote_transmitter +from esphome.components import ( + radio_frequency, + remote_base, + remote_receiver, + remote_transmitter, +) import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -66,5 +71,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transmitter(transmitter)) if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 0e03bca2336..07ad02d3afc 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import climate, remote_transmitter, sensor, uart +from esphome.components import climate, remote_base, remote_transmitter, sensor, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode from esphome.components.remote_base import CONF_TRANSMITTER_ID import esphome.config_validation as cv @@ -280,6 +280,7 @@ async def to_code(config): cg.add(var.set_response_timeout(config[CONF_TIMEOUT].total_milliseconds)) cg.add(var.set_request_attempts(config[CONF_NUM_ATTEMPTS])) if CONF_TRANSMITTER_ID in config: + remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) cg.add(var.set_transmitter(transmitter_)) diff --git a/esphome/components/midea_ir/climate.py b/esphome/components/midea_ir/climate.py index 84bfeab0d46..e1b2b56ada4 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT from esphome.types import ConfigType @@ -19,5 +19,9 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( async def to_code(config: ConfigType) -> None: + # midea_ir uses MideaProtocol from C++ and auto-loads coolix, whose coolix.cpp uses + # CoolixProtocol even when no coolix climate is configured + remote_base.request_protocol("midea") + remote_base.request_protocol("coolix") var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 19b8549f75a..27b6eb9fc82 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,6 +1,11 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -40,11 +45,14 @@ from esphome.const import ( CONF_ZERO, ) from esphome.core import ID, coroutine +from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] + CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -90,9 +98,42 @@ REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( ) -async def register_listener(var, config): +# Listener and dumper lists are StaticVectors sized from these counts, so every registration +# must go through add_listener / add_dumper. Every receiver's list gets the same capacity, so +# the slots are keyed by receiver and the define is the largest count any one receiver needs. +LISTENER_COUNT_DEFINE = "REMOTE_BASE_LISTENER_COUNT" +DUMPER_COUNT_DEFINE = "REMOTE_BASE_DUMPER_COUNT" + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) +_request_dumper_slot = cg.slot_counter(DUMPER_COUNT_DEFINE) + + +def add_listener(receiver: MockObj, listener: MockObj) -> None: + _request_listener_slot(str(receiver)) + cg.add(receiver.register_listener(listener)) + + +def add_dumper(receiver: MockObj, dumper: MockObj) -> None: + _request_dumper_slot(str(receiver)) + cg.add(receiver.register_dumper(dumper)) + + +async def register_listener(var: MockObj, config: ConfigType) -> None: receiver = await cg.get_variable(config[CONF_RECEIVER_ID]) - cg.add(receiver.register_listener(var)) + add_listener(receiver, var) + + +async def attach_receiver( + var: MockObj, config: ConfigType, key: str = CONF_RECEIVER_ID +) -> None: + """Link the configured receiver to an entity and register the entity as its listener. + + The C++ set_receiver() no longer registers the listener; the slot for it is counted here. + """ + receiver = await cg.get_variable(config[key]) + cg.add(var.set_receiver(receiver)) + add_listener(receiver, var) async def register_transmittable(var, config): @@ -100,8 +141,53 @@ async def register_transmittable(var, config): cg.add(var.set_transmitter(transmitter_)) -def register_binary_sensor(name, type, schema): - return BINARY_SENSOR_REGISTRY.register(name, type, schema) +# Registry names that share a protocol source file +def _protocol_stem(name: str) -> str: + if name.startswith("rc_switch"): + return "rc_switch" + if name == "canalsatld": + return "canalsat" + return name + + +def protocol_define(name: str) -> str: + return f"USE_REMOTE_PROTOCOL_{_protocol_stem(name).upper()}" + + +_PROTOCOL_STEMS = sorted( + path.name.removesuffix("_protocol.cpp") + for path in Path(__file__).parent.glob("*_protocol.cpp") +) + + +def request_protocol(name: str) -> None: + """Keep a protocol's source file in the build; components using it from C++ must call this.""" + if _protocol_stem(name) not in _PROTOCOL_STEMS: + raise ValueError( + f"Unknown remote protocol {name!r}; expected one of {', '.join(_PROTOCOL_STEMS)}" + ) + cg.add_define(protocol_define(name)) + + +# Only the protocol sources a configuration uses are compiled +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} +) + + +def register_binary_sensor( + name: str, type: MockObj, schema: cv.Schema | dict +) -> Callable[[Callable[[MockObj, ConfigType], Any]], Callable]: + registerer = BINARY_SENSOR_REGISTRY.register(name, type, schema) + + def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: + async def new_func(var: MockObj, config: ConfigType) -> None: + request_protocol(name) + await coroutine(func)(var, config) + + return registerer(new_func) + + return decorator def register_trigger(name, type, data_type): @@ -114,6 +200,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): + request_protocol(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -131,6 +218,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): + request_protocol(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -171,6 +259,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): + request_protocol(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: @@ -213,7 +302,13 @@ DUMPER_REGISTRY = Registry() def validate_dumpers(value): if isinstance(value, str) and value.lower() == "all": return validate_dumpers(list(DUMPER_REGISTRY.keys())) - return cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + entries = cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + # a dumper listed twice would register twice; the receiver holds one secondary dumper + return list( + { + next(k for k in entry if k in DUMPER_REGISTRY): entry for entry in entries + }.values() + ) def validate_triggers(base_schema): @@ -1439,7 +1534,7 @@ def validate_rc_switch_raw_code(value): def build_rc_switch_protocol(config): if isinstance(config, int): - return rc_switch_protocols[config] + return rc_switch_protocol(config) pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1526,7 +1621,7 @@ RC_SWITCH_TRANSMITTER = cv.Schema( } ) -rc_switch_protocols = ns.RC_SWITCH_PROTOCOLS +rc_switch_protocol = ns.rc_switch_protocol RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 7ff32923bef..a309c124eed 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -191,9 +191,9 @@ class ABBWelcomeData { class ABBWelcomeProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ABBWelcomeData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const ABBWelcomeData &data) override; + void encode(RemoteTransmitData *dst, const ABBWelcomeData &src); + optional decode(RemoteReceiveData src); + void dump(const ABBWelcomeData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t data) const; diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 3f4e98bd438..98a55011552 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,9 +15,9 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const AEHAData &data) override; + void encode(RemoteTransmitData *dst, const AEHAData &data); + optional decode(RemoteReceiveData src); + void dump(const AEHAData &data); private: std::string format_data_(const std::vector &data); diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index 30b99dbeb77..ed9d6aa6712 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -16,9 +16,9 @@ struct Beo4Data { class Beo4Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Beo4Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Beo4Data &data) override; + void encode(RemoteTransmitData *dst, const Beo4Data &data); + optional decode(RemoteReceiveData src); + void dump(const Beo4Data &data); }; DECLARE_REMOTE_PROTOCOL(Beo4) diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index 1d5b6217147..bfea463b7d4 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -13,9 +13,9 @@ struct BrennenstuhlData { class BrennenstuhlProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const BrennenstuhlData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const BrennenstuhlData &data) override; + void encode(RemoteTransmitData *dst, const BrennenstuhlData &data); + optional decode(RemoteReceiveData src); + void dump(const BrennenstuhlData &data); }; DECLARE_REMOTE_PROTOCOL(Brennenstuhl) diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index 674fa99ea10..c71390c267c 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -21,9 +21,9 @@ struct ByronSXData { class ByronSXProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ByronSXData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ByronSXData &data) override; + void encode(RemoteTransmitData *dst, const ByronSXData &data); + optional decode(RemoteReceiveData src); + void dump(const ByronSXData &data); }; DECLARE_REMOTE_PROTOCOL(ByronSX) diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 5ba9115ea86..09bead18b3c 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -19,9 +19,9 @@ struct CanalSatLDData : public CanalSatData {}; class CanalSatBaseProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CanalSatData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const CanalSatData &data) override; + void encode(RemoteTransmitData *dst, const CanalSatData &data); + optional decode(RemoteReceiveData src); + void dump(const CanalSatData &data); protected: uint16_t frequency_; diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index d9441e84178..29a306ce291 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -21,9 +21,9 @@ struct CoolixData { class CoolixProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CoolixData &data) override; - optional decode(RemoteReceiveData data) override; - void dump(const CoolixData &data) override; + void encode(RemoteTransmitData *dst, const CoolixData &data); + optional decode(RemoteReceiveData data); + void dump(const CoolixData &data); }; DECLARE_REMOTE_PROTOCOL(Coolix) diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index c89f4e78e11..f319b55f432 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -13,9 +13,9 @@ struct DishData { class DishProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DishData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DishData &data) override; + void encode(RemoteTransmitData *dst, const DishData &data); + optional decode(RemoteReceiveData src); + void dump(const DishData &data); }; DECLARE_REMOTE_PROTOCOL(Dish) diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 148c7c17bc8..954c3cf1d38 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -20,9 +20,9 @@ struct DooyaData { class DooyaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DooyaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DooyaData &data) override; + void encode(RemoteTransmitData *dst, const DooyaData &data); + optional decode(RemoteReceiveData src); + void dump(const DooyaData &data); }; DECLARE_REMOTE_PROTOCOL(Dooya) diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 693a1bbe85b..4e879f0f75b 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -19,9 +19,9 @@ struct DraytonData { class DraytonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DraytonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DraytonData &data) override; + void encode(RemoteTransmitData *dst, const DraytonData &data); + optional decode(RemoteReceiveData src); + void dump(const DraytonData &data); }; DECLARE_REMOTE_PROTOCOL(Drayton) diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 3473a489b2c..663e50fb4b5 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -21,9 +21,9 @@ struct DysonData { class DysonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DysonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DysonData &data) override; + void encode(RemoteTransmitData *dst, const DysonData &data); + optional decode(RemoteReceiveData src); + void dump(const DysonData &data); }; DECLARE_REMOTE_PROTOCOL(Dyson) diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index f6b278771e0..0c8797af70c 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -31,9 +31,9 @@ class GoboxProtocol : public RemoteProtocol { void dump_timings_(const RawTimings &timings) const; public: - void encode(RemoteTransmitData *dst, const GoboxData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const GoboxData &data) override; + void encode(RemoteTransmitData *dst, const GoboxData &data); + optional decode(RemoteReceiveData src); + void dump(const GoboxData &data); }; DECLARE_REMOTE_PROTOCOL(Gobox) diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 9c45ba1a635..e1fd60411fc 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,9 +13,9 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const HaierData &data) override; + void encode(RemoteTransmitData *dst, const HaierData &data); + optional decode(RemoteReceiveData src); + void dump(const HaierData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index f6e2548dead..5911664fc39 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -14,9 +14,9 @@ struct JVCData { class JVCProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const JVCData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const JVCData &data) override; + void encode(RemoteTransmitData *dst, const JVCData &data); + optional decode(RemoteReceiveData src); + void dump(const JVCData &data); }; DECLARE_REMOTE_PROTOCOL(JVC) diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 432313b87b2..335fbd164b1 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -24,9 +24,9 @@ struct KeeloqData { class KeeloqProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const KeeloqData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const KeeloqData &data) override; + void encode(RemoteTransmitData *dst, const KeeloqData &data); + optional decode(RemoteReceiveData src); + void dump(const KeeloqData &data); }; DECLARE_REMOTE_PROTOCOL(Keeloq) diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 97159749956..91dfbadb0c2 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -16,9 +16,9 @@ struct LGData { class LGProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const LGData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const LGData &data) override; + void encode(RemoteTransmitData *dst, const LGData &data); + optional decode(RemoteReceiveData src); + void dump(const LGData &data); }; DECLARE_REMOTE_PROTOCOL(LG) diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 18662ec7598..f0d2410fe27 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -27,9 +27,9 @@ struct MagiQuestData { class MagiQuestProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MagiQuestData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MagiQuestData &data) override; + void encode(RemoteTransmitData *dst, const MagiQuestData &data); + optional decode(RemoteReceiveData src); + void dump(const MagiQuestData &data); }; DECLARE_REMOTE_PROTOCOL(MagiQuest) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 47bad6826fc..85bbef1cb1f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -67,9 +67,9 @@ class MideaData { class MideaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MideaData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const MideaData &data) override; + void encode(RemoteTransmitData *dst, const MideaData &src); + optional decode(RemoteReceiveData src); + void dump(const MideaData &data); }; DECLARE_REMOTE_PROTOCOL(Midea) diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index c967e72f134..a37fb93f4fd 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,9 +13,9 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MirageData &data) override; + void encode(RemoteTransmitData *dst, const MirageData &data); + optional decode(RemoteReceiveData src); + void dump(const MirageData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 7b310e8ba5b..1337f7a8b32 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -14,9 +14,9 @@ struct NECData { class NECProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const NECData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NECData &data) override; + void encode(RemoteTransmitData *dst, const NECData &data); + optional decode(RemoteReceiveData src); + void dump(const NECData &data); }; DECLARE_REMOTE_PROTOCOL(NEC) diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebcd2a2c113..ebf85387b04 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -24,9 +24,9 @@ class NexaProtocol : public RemoteProtocol { void zero(RemoteTransmitData *dst) const; void sync(RemoteTransmitData *dst) const; - void encode(RemoteTransmitData *dst, const NexaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NexaData &data) override; + void encode(RemoteTransmitData *dst, const NexaData &data); + optional decode(RemoteReceiveData src); + void dump(const NexaData &data); }; DECLARE_REMOTE_PROTOCOL(Nexa) diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index d13c0f27985..84df3c08b72 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -16,9 +16,9 @@ struct PanasonicData { class PanasonicProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PanasonicData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PanasonicData &data) override; + void encode(RemoteTransmitData *dst, const PanasonicData &data); + optional decode(RemoteReceiveData src); + void dump(const PanasonicData &data); }; DECLARE_REMOTE_PROTOCOL(Panasonic) diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index 514ab675016..d02bd3451f2 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -13,9 +13,9 @@ struct PioneerData { class PioneerProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PioneerData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PioneerData &data) override; + void encode(RemoteTransmitData *dst, const PioneerData &data); + optional decode(RemoteReceiveData src); + void dump(const PioneerData &data); }; DECLARE_REMOTE_PROTOCOL(Pioneer) diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index f4f6b2144d9..bfd04c5cd9a 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -30,9 +30,9 @@ class ProntoProtocol : public RemoteProtocol { std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); public: - void encode(RemoteTransmitData *dst, const ProntoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ProntoData &data) override; + void encode(RemoteTransmitData *dst, const ProntoData &data); + optional decode(RemoteReceiveData src); + void dump(const ProntoData &data); }; DECLARE_REMOTE_PROTOCOL(Pronto) diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index dbb89e41c60..f6f0f33c6e2 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -14,9 +14,9 @@ struct RC5Data { class RC5Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC5Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC5Data &data) override; + void encode(RemoteTransmitData *dst, const RC5Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC5Data &data); }; DECLARE_REMOTE_PROTOCOL(RC5) diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index fda9d98ecbb..c4a2e8529bb 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -15,9 +15,9 @@ struct RC6Data { class RC6Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC6Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC6Data &data) override; + void encode(RemoteTransmitData *dst, const RC6Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC6Data &data); }; DECLARE_REMOTE_PROTOCOL(RC6) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index 612558ca1c9..de16c55cb02 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -1,29 +1,21 @@ #include "rc_switch_protocol.h" + +#include +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; -const RCSwitchBase RC_SWITCH_PROTOCOLS[9] = {RCSwitchBase(0, 0, 0, 0, 0, 0, false), - RCSwitchBase(350, 10850, 350, 1050, 1050, 350, false), - RCSwitchBase(650, 6500, 650, 1300, 1300, 650, false), - RCSwitchBase(3000, 7100, 400, 1100, 900, 600, false), - RCSwitchBase(380, 2280, 380, 1140, 1140, 380, false), - RCSwitchBase(3000, 7000, 500, 1000, 1000, 500, false), - RCSwitchBase(10350, 450, 450, 900, 900, 450, true), - RCSwitchBase(300, 9300, 150, 900, 900, 150, false), - RCSwitchBase(250, 2500, 250, 1250, 250, 250, false)}; - -RCSwitchBase::RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, - uint32_t one_high, uint32_t one_low, bool inverted) - : sync_high_(sync_high), - sync_low_(sync_low), - zero_high_(zero_high), - zero_low_(zero_low), - one_high_(one_high), - one_low_(one_low), - inverted_(inverted) {} +RCSwitchBase rc_switch_protocol(uint8_t index) { + RCSwitchBase protocol; + // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing + if (index >= std::size(RC_SWITCH_PROTOCOLS)) + index = 0; + progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); + return protocol; +} void RCSwitchBase::one(RemoteTransmitData *dst) const { if (!this->inverted_) { @@ -133,11 +125,11 @@ bool RCSwitchBase::decode(RemoteReceiveData &src, uint64_t *out_data, uint8_t *o optional RCSwitchBase::decode(RemoteReceiveData &src) const { RCSwitchData out; uint8_t out_nbits; - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); const RCSwitchBase *protocol = &RC_SWITCH_PROTOCOLS[i]; if (protocol->decode(src, &out.code, &out_nbits) && out_nbits >= 3) { - out.protocol = i; + out.protocol = static_cast(i); return out; } } @@ -246,7 +238,7 @@ bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); } bool RCSwitchDumper::dump(RemoteReceiveData src) { - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); uint64_t out_data; uint8_t out_nbits; @@ -257,7 +249,7 @@ bool RCSwitchDumper::dump(RemoteReceiveData src) { buffer[j] = (out_data & ((uint64_t) 1 << (out_nbits - j - 1))) ? '1' : '0'; buffer[out_nbits] = '\0'; - ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", i, buffer); + ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", static_cast(i), buffer); // only send first decoded protocol return true; diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 3224c04fb29..9ccea4d15a5 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -16,9 +16,16 @@ class RCSwitchBase { public: using ProtocolData = RCSwitchData; - RCSwitchBase() = default; - RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, uint32_t one_high, - uint32_t one_low, bool inverted); + constexpr RCSwitchBase() = default; + constexpr RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, + uint32_t one_high, uint32_t one_low, bool inverted) + : sync_high_(sync_high), + sync_low_(sync_low), + zero_high_(zero_high), + zero_low_(zero_low), + one_high_(one_high), + one_low_(one_low), + inverted_(inverted) {} void one(RemoteTransmitData *dst) const; @@ -58,10 +65,28 @@ class RCSwitchBase { uint32_t zero_low_{}; uint32_t one_high_{}; uint32_t one_low_{}; - bool inverted_{}; + uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash }; -extern const RCSwitchBase RC_SWITCH_PROTOCOLS[9]; +// Constant-initialized and kept in flash on every platform. The decoder reads entries in place +// through a pointer, which ESP8266 only allows while every field is a whole word; copies out of +// the table go through rc_switch_protocol() +static_assert(sizeof(RCSwitchBase) == 7 * sizeof(uint32_t), "RCSwitchBase must stay word-only for flash reads"); +inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { + {0, 0, 0, 0, 0, 0, false}, + {350, 10850, 350, 1050, 1050, 350, false}, + {650, 6500, 650, 1300, 1300, 650, false}, + {3000, 7100, 400, 1100, 900, 600, false}, + {380, 2280, 380, 1140, 1140, 380, false}, + {3000, 7000, 500, 1000, 1000, 500, false}, + {10350, 450, 450, 900, 900, 450, true}, + {300, 9300, 150, 900, 900, 150, false}, + {250, 2500, 250, 1250, 250, 250, false}, +}; + +/// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with +/// progmem_memcpy so no byte load ever touches the flash table on ESP8266 +RCSwitchBase rc_switch_protocol(uint8_t index); uint64_t decode_binary_string(const std::string &data); diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 4d9bc55f216..5d1bba16b61 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -99,29 +99,48 @@ bool RemoteReceiverBinarySensorBase::on_receive(RemoteReceiveData src) { /* RemoteReceiverBase */ +// Slots are counted at code generation; a registration from C++ setup() has none +#ifdef REMOTE_BASE_LISTENER_COUNT +void RemoteReceiverBase::register_listener(RemoteReceiverListener *listener) { + if (this->listeners_.size() == REMOTE_BASE_LISTENER_COUNT) { + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("listener"), + LOG_STR_LITERAL("listener")); + return; + } + this->listeners_.push_back(listener); +} +#endif + +#ifdef REMOTE_BASE_DUMPER_COUNT void RemoteReceiverBase::register_dumper(RemoteReceiverDumperBase *dumper) { if (dumper->is_secondary()) { - this->secondary_dumpers_.push_back(dumper); - } else { + if (this->secondary_dumper_ == nullptr) { + this->secondary_dumper_ = dumper; + return; + } + } else if (this->dumpers_.size() != REMOTE_BASE_DUMPER_COUNT) { this->dumpers_.push_back(dumper); + return; } + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("dumper"), + LOG_STR_LITERAL("dumper")); } +#endif -void RemoteReceiverBase::call_listeners_() { +void RemoteReceiverBase::call_listeners_dumpers_() { +#ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); -} - -void RemoteReceiverBase::call_dumpers_() { +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; for (auto *dumper : this->dumpers_) { if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) success = true; } - if (!success) { - for (auto *dumper : this->secondary_dumpers_) - dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); - } + if (!success && this->secondary_dumper_ != nullptr) + this->secondary_dumper_->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); +#endif } void RemoteReceiverBinarySensorBase::dump_config() { LOG_BINARY_SENSOR("", "Remote Receiver Binary Sensor", this); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 4e2ed4b71cb..67e5799bcaf 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,12 +1,14 @@ +#pragma once + +#include #include #include -#pragma once - #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::remote_base { @@ -141,6 +143,22 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +// Protocol shapes, checked where a protocol is used so a missing method fails at the use site +// instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode +// without encoding. +template +concept RemoteProtocolDecoder = requires(T proto, RemoteReceiveData src) { + { proto.decode(src) } -> std::same_as>; +}; +template +concept RemoteProtocolDumper = RemoteProtocolDecoder && requires(T proto, const typename T::ProtocolData &data) { + proto.dump(data); +}; +template +concept RemoteProtocolEncoder = requires(T proto, RemoteTransmitData *dst, const typename T::ProtocolData &data) { + proto.encode(dst, data); +}; + class RemoteTransmitterBase : public RemoteComponentBase { public: RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} @@ -162,8 +180,8 @@ class RemoteTransmitterBase : public RemoteComponentBase { this->temp_.reset(); return TransmitCall(this); } - template - void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -194,24 +212,37 @@ class RemoteReceiverDumperBase { class RemoteReceiverBase : public RemoteComponentBase { public: RemoteReceiverBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} - void register_listener(RemoteReceiverListener *listener) { this->listeners_.push_back(listener); } + // Slots are counted at code generation; without one the call fails at compile time with the same message + // the runtime check logs +#ifdef REMOTE_BASE_LISTENER_COUNT + void register_listener(RemoteReceiverListener *listener); +#else + template void register_listener(T *) { + static_assert(sizeof(T) == 0, "No listener slot: register it from to_code() with remote_base.add_listener"); + } +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT void register_dumper(RemoteReceiverDumperBase *dumper); +#else + template void register_dumper(T *) { + static_assert(sizeof(T) == 0, "No dumper slot: register it from to_code() with remote_base.add_dumper"); + } +#endif void set_tolerance(uint32_t tolerance, ToleranceMode tolerance_mode) { this->tolerance_ = tolerance; this->tolerance_mode_ = tolerance_mode; } protected: - void call_listeners_(); - void call_dumpers_(); - void call_listeners_dumpers_() { - this->call_listeners_(); - this->call_dumpers_(); - } + void call_listeners_dumpers_(); - std::vector listeners_; - std::vector dumpers_; - std::vector secondary_dumpers_; +#ifdef REMOTE_BASE_LISTENER_COUNT + StaticVector listeners_; +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT + StaticVector dumpers_; + RemoteReceiverDumperBase *secondary_dumper_{nullptr}; // runs only when no primary dumper matched +#endif RawTimings temp_; uint32_t tolerance_{25}; ToleranceMode tolerance_mode_{TOLERANCE_MODE_PERCENTAGE}; @@ -229,15 +260,14 @@ class RemoteReceiverBinarySensorBase : public binary_sensor::BinarySensorInitial /* TEMPLATES */ +// Protocols are used only through their concrete type (see the RemoteProtocol* concepts); encode/decode/dump +// stay non-virtual so unused ones link out template class RemoteProtocol { public: using ProtocolData = T; - virtual void encode(RemoteTransmitData *dst, const ProtocolData &data) = 0; - virtual optional decode(RemoteReceiveData src) = 0; - virtual void dump(const ProtocolData &data) = 0; }; -template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { +template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { public: RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {} @@ -255,7 +285,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin T::ProtocolData data_; }; -template +template class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { @@ -276,8 +306,8 @@ class RemoteTransmittable { void set_transmitter(RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } protected: - template - void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; @@ -298,7 +328,7 @@ template class RemoteTransmitterActionBase : public RemoteTransm virtual void encode(RemoteTransmitData *dst, Ts... x) = 0; }; -template class RemoteReceiverDumper : public RemoteReceiverDumperBase { +template class RemoteReceiverDumper : public RemoteReceiverDumperBase { public: bool dump(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 3582dac398b..8db025f812a 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -12,9 +12,9 @@ struct RoombaData { class RoombaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RoombaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RoombaData &data) override; + void encode(RemoteTransmitData *dst, const RoombaData &data); + optional decode(RemoteReceiveData src); + void dump(const RoombaData &data); }; DECLARE_REMOTE_PROTOCOL(Roomba) diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index 4f15d906e76..df4e1af8d8c 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -16,9 +16,9 @@ struct Samsung36Data { class Samsung36Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Samsung36Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Samsung36Data &data) override; + void encode(RemoteTransmitData *dst, const Samsung36Data &data); + optional decode(RemoteReceiveData src); + void dump(const Samsung36Data &data); }; DECLARE_REMOTE_PROTOCOL(Samsung36) diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index bb234d681de..dfa22ff85ce 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -14,9 +14,9 @@ struct SamsungData { class SamsungProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SamsungData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SamsungData &data) override; + void encode(RemoteTransmitData *dst, const SamsungData &data); + optional decode(RemoteReceiveData src); + void dump(const SamsungData &data); }; DECLARE_REMOTE_PROTOCOL(Samsung) diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index eb873e8b7dc..f83b2908b61 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -16,9 +16,9 @@ struct SonyData { class SonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SonyData &data) override; + void encode(RemoteTransmitData *dst, const SonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SonyData &data); }; DECLARE_REMOTE_PROTOCOL(Sony) diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 7caf5eab867..40a5c2daec9 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -17,9 +17,9 @@ struct SymphonyData { class SymphonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SymphonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SymphonyData &data) override; + void encode(RemoteTransmitData *dst, const SymphonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SymphonyData &data); }; DECLARE_REMOTE_PROTOCOL(Symphony) diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 8a853005acb..35d5af314cb 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -14,9 +14,9 @@ struct ToshibaAcData { class ToshibaAcProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ToshibaAcData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ToshibaAcData &data) override; + void encode(RemoteTransmitData *dst, const ToshibaAcData &data); + optional decode(RemoteReceiveData src); + void dump(const ToshibaAcData &data); }; DECLARE_REMOTE_PROTOCOL(ToshibaAc) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 285c9f21257..8e965a5c739 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -16,9 +16,9 @@ struct TotoData { class TotoProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const TotoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const TotoData &data) override; + void encode(RemoteTransmitData *dst, const TotoData &data); + optional decode(RemoteReceiveData src); + void dump(const TotoData &data); }; DECLARE_REMOTE_PROTOCOL(Toto) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6e8c73d331a..b2fd87165e4 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -221,11 +221,11 @@ async def to_code(config: ConfigType) -> None: dumpers = await remote_base.build_dumpers(config[CONF_DUMP]) for dumper in dumpers: - cg.add(var.register_dumper(dumper)) + remote_base.add_dumper(var, dumper) triggers = await remote_base.build_triggers(config) for trigger in triggers: - cg.add(var.register_listener(trigger)) + remote_base.add_listener(var, trigger) await cg.register_component(var, config) cg.add( diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 3b1e7352f98..e5f8544f2fe 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_MODEL from esphome.types import ConfigType @@ -26,5 +26,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).exten async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("toshiba_ac") # used from C++ var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9144e65576f..6b9b9eda43f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,43 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define REMOTE_BASE_DUMPER_COUNT 1 +#define REMOTE_BASE_LISTENER_COUNT 1 +#define USE_REMOTE_PROTOCOL_ABBWELCOME +#define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_BEO4 +#define USE_REMOTE_PROTOCOL_BRENNENSTUHL +#define USE_REMOTE_PROTOCOL_BYRONSX +#define USE_REMOTE_PROTOCOL_CANALSAT +#define USE_REMOTE_PROTOCOL_COOLIX +#define USE_REMOTE_PROTOCOL_DISH +#define USE_REMOTE_PROTOCOL_DOOYA +#define USE_REMOTE_PROTOCOL_DRAYTON +#define USE_REMOTE_PROTOCOL_DYSON +#define USE_REMOTE_PROTOCOL_GOBOX +#define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_JVC +#define USE_REMOTE_PROTOCOL_KEELOQ +#define USE_REMOTE_PROTOCOL_LG +#define USE_REMOTE_PROTOCOL_MAGIQUEST +#define USE_REMOTE_PROTOCOL_MIDEA +#define USE_REMOTE_PROTOCOL_MIRAGE +#define USE_REMOTE_PROTOCOL_NEC +#define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PRONTO +#define USE_REMOTE_PROTOCOL_RAW +#define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC6 +#define USE_REMOTE_PROTOCOL_RC_SWITCH +#define USE_REMOTE_PROTOCOL_ROOMBA +#define USE_REMOTE_PROTOCOL_SAMSUNG +#define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SONY +#define USE_REMOTE_PROTOCOL_SYMPHONY +#define USE_REMOTE_PROTOCOL_TOSHIBA_AC +#define USE_REMOTE_PROTOCOL_TOTO #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 53b59cb1240..fc44d27f472 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from dataclasses import dataclass, field import logging @@ -142,9 +142,10 @@ _SLOT_COUNTER_DOMAIN = "slot_counter" @dataclass class _SlotCounterState: - """Per-run slot counter state: requested counts and already-emitted defines.""" + """Per-run slot counter state: requested counts per define and key, and + already-emitted defines.""" - counts: dict[str, int] = field(default_factory=dict) + counts: dict[str, dict[Hashable, int]] = field(default_factory=dict) emitted: set[str] = field(default_factory=set) @@ -156,11 +157,13 @@ def _get_slot_counter_state() -> _SlotCounterState: def get_slot_count(define: str) -> int: - """Number of slots requested so far for `define`.""" - return _get_slot_counter_state().counts.get(define, 0) + """Value `define` would be emitted with so far: the largest count requested + under any one key, which is the plain request count when no key is used.""" + counts = _get_slot_counter_state().counts.get(define) + return max(counts.values()) if counts else 0 -def slot_counter(define: str) -> Callable[[], None]: +def slot_counter(define: str) -> Callable[..., None]: """Create a request_slot function for codegen-sized storage. The pattern behind a StaticVector listener array: a consumer's to_code @@ -169,6 +172,11 @@ def slot_counter(define: str) -> Callable[[], None]: emitted with the requested count. No requests, no define: the guarded storage and its registration method compile out entirely. + When several objects each declare the storage at the same size (one list + per receiver, per hub, ...) the caller passes the owning object as `key` + and the define becomes the largest count any one key requested, not the + total. Requests without a key share one count. + The counts live in a table under CORE.data, which clears between runs. A request arriving after the define was already emitted raises instead of silently undercounting: the define would keep the stale smaller value and @@ -179,10 +187,10 @@ def slot_counter(define: str) -> Callable[[], None]: async def emit_job() -> None: state = _get_slot_counter_state() state.emitted.add(define) - # Scheduled only by the first request, so the count is always >= 1 here. - add_define(define, state.counts[define]) + # Scheduled only by the first request, so there is at least one count here. + add_define(define, max(state.counts[define].values())) - def request_slot() -> None: + def request_slot(key: Hashable = None) -> None: state = _get_slot_counter_state() if define in state.emitted: raise ValueError( @@ -190,10 +198,16 @@ def slot_counter(define: str) -> Callable[[], None]: f"define was emitted; request slots from to_code, not from a " f"job running after FINAL emission" ) - counts = state.counts - counts[define] = (count := counts.get(define, 0) + 1) - if count == 1: + counts = state.counts.get(define) + if counts is None: + counts = state.counts[define] = {} CORE.add_job(emit_job) + elif (key is None) != (None in counts): + # a keyed and an unkeyed request would compare buckets instead of adding up + raise ValueError( + f"slot_counter('{define}'): every request must use a key, or none of them" + ) + counts[key] = counts.get(key, 0) + 1 return request_slot diff --git a/tests/component_tests/remote_receiver/__init__.py b/tests/component_tests/remote_receiver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/remote_receiver/config/receiver_bare.yaml b/tests/component_tests/remote_receiver/config/receiver_bare.yaml new file mode 100644 index 00000000000..b4741948015 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bare.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml new file mode 100644 index 00000000000..32c1b07f579 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_dumpers.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - nec + - rc_switch + on_nec: + then: + - logger.log: nec + +binary_sensor: + - platform: remote_receiver + name: Remote Input + nec: + address: 0x1234 + command: 0x5678 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml new file mode 100644 index 00000000000..c443a842f23 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_proxies.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr_ir + pin: GPIO4 + - id: rcvr_rf + pin: GPIO5 + +infrared: + - platform: ir_rf_proxy + name: IR Receiver + remote_receiver_id: rcvr_ir + +radio_frequency: + - platform: ir_rf_proxy + name: RF Receiver + frequency: 433.92MHz + remote_receiver_id: rcvr_rf diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py new file mode 100644 index 00000000000..f381a640929 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -0,0 +1,91 @@ +"""Listener and dumper StaticVector sizes come from codegen slot counts.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components import remote_base +import esphome.config_validation as cv + +from ..helpers import get_define_value + + +def test_dumper_and_listener_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + # nec and rc_switch dumpers + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + # on_nec trigger plus the remote_receiver binary sensor + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "2" + + +def test_bare_receiver_emits_no_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_bare.yaml")) + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") is None + + +def test_proxy_receivers_count_as_listeners( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_proxies.yaml")) + # one proxy entity listens on each of the two receivers; every receiver's list gets the + # capacity of the busiest one, so this is the largest per receiver count, not the sum + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "1" + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None + + +def test_only_used_protocol_sources_are_compiled( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("receiver_with_dumpers.yaml")) + excluded = set(remote_base.FILTER_SOURCE_FILES()) + assert "nec_protocol.cpp" not in excluded + assert "rc_switch_protocol.cpp" not in excluded + assert "sony_protocol.cpp" in excluded + assert "remote_base.cpp" not in excluded + + +def test_every_registry_name_maps_to_a_protocol_source() -> None: + """A registry name must resolve to a source file or request_protocol rejects it.""" + names = ( + set(remote_base.BINARY_SENSOR_REGISTRY) + | set(remote_base.DUMPER_REGISTRY) + | {key.removeprefix("on_") for key in remote_base.TRIGGER_REGISTRY} + | { + key.removeprefix("remote_transmitter.transmit_") + for key in ACTION_REGISTRY + if key.startswith("remote_transmitter.transmit_") + } + ) + assert len(names) > 40 + for name in names: + assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name + + +def test_request_protocol_rejects_unknown_names() -> None: + """A misspelled protocol would otherwise surface only as a link error.""" + with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): + remote_base.request_protocol("toshiba") + + +def test_dump_list_is_deduplicated_across_forms() -> None: + dumpers = remote_base.validate_dumpers(["raw", {"raw": None}, "nec", "nec"]) + assert [ + next(k for k in entry if k in remote_base.DUMPER_REGISTRY) for entry in dumpers + ] == ["raw", "nec"] + + +@pytest.mark.parametrize("bad", [["nec", None], [5]]) +def test_dump_list_rejects_invalid_entries_with_a_validation_error(bad: list) -> None: + with pytest.raises(cv.Invalid): + remote_base.validate_dumpers(bad) diff --git a/tests/components/remote_receiver/bare-common.yaml b/tests/components/remote_receiver/bare-common.yaml new file mode 100644 index 00000000000..c100c5c2da4 --- /dev/null +++ b/tests/components/remote_receiver/bare-common.yaml @@ -0,0 +1,6 @@ +# A receiver with no dumpers and no listeners compiles both lists out. +# Only built while remote_receiver is tested in isolation: the counts are global defines, +# so this variant cannot be merged with configs that register any. +remote_receiver: + - id: rcvr_bare + pin: ${pin} diff --git a/tests/components/remote_receiver/test-bare.esp32-idf.yaml b/tests/components/remote_receiver/test-bare.esp32-idf.yaml new file mode 100644 index 00000000000..152853b65fb --- /dev/null +++ b/tests/components/remote_receiver/test-bare.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + pin: GPIO2 + +packages: + bare: !include bare-common.yaml diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 1c0e0d0a931..725c1daebb4 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -187,6 +187,31 @@ def test_slot_counter_emits_requested_count() -> None: assert _define_value("TEST_SLOT_COUNT") == "2" +def test_slot_counter_keyed_emits_largest_count() -> None: + """Keyed requests size storage every key declares at the same capacity: + the define is the busiest key's count, not the total over all keys.""" + request = ch.slot_counter("TEST_SLOT_COUNT_KEYED") + request("rx_a") + request("rx_a") + request("rx_a") + request("rx_b") + assert ch.get_slot_count("TEST_SLOT_COUNT_KEYED") == 3 + ch.CORE.flush_tasks() + assert _define_value("TEST_SLOT_COUNT_KEYED") == "3" + + +def test_slot_counter_rejects_mixed_keyed_and_unkeyed_requests() -> None: + """A keyed and an unkeyed request for one define cannot be sized together.""" + request = ch.slot_counter("TEST_SLOT_COUNT_MIXED") + request("rx_a") + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED"): + request() + unkeyed = ch.slot_counter("TEST_SLOT_COUNT_MIXED_2") + unkeyed() + with pytest.raises(ValueError, match="TEST_SLOT_COUNT_MIXED_2"): + unkeyed("rx_a") + + def test_slot_counter_without_requests_emits_nothing() -> None: """No requests, no job, no define — the guarded storage compiles out.""" ch.slot_counter("TEST_SLOT_COUNT_UNUSED") From 2578f17dc8705b0a18ce4a67f6de4bad8b2c6101 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:41 -0500 Subject: [PATCH 090/759] Bump bundled esphome-device-builder to 1.14.7 (#19096) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cfa47fbdad2..6f500dbe6f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 RUN \ platformio settings set enable_telemetry No \ From 2b71d5496d1c415e335a637c0839259d2ba7f39a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:45:02 -0400 Subject: [PATCH 091/759] [const] Centralize definition of `CONF_MANUFACTURER` (#19098) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_ble_server/__init__.py | 2 +- esphome/components/sendspin/__init__.py | 2 +- tests/component_tests/sendspin/test_device_info.py | 7 ++----- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49a625e3f1f..256ab5c0a3c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -30,6 +30,7 @@ CONF_KEYS = "keys" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_MANUFACTURER = "manufacturer" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d8095cd7020..118ae06e420 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,6 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -41,7 +42,6 @@ CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" CONF_INDICATE = "indicate" -CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c21047c70a7..fda4d4f954c 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi +from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -33,7 +34,6 @@ CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_FIRMWARE_VERSION = "firmware_version" -CONF_MANUFACTURER = "manufacturer" # An empty device information string would be sent to the server as an empty value rather than # falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps diff --git a/tests/component_tests/sendspin/test_device_info.py b/tests/component_tests/sendspin/test_device_info.py index 833dd398b41..61c10676dad 100644 --- a/tests/component_tests/sendspin/test_device_info.py +++ b/tests/component_tests/sendspin/test_device_info.py @@ -8,11 +8,8 @@ from pathlib import Path import pytest from esphome import config_validation as cv -from esphome.components.sendspin import ( - CONF_FIRMWARE_VERSION, - CONF_MANUFACTURER, - CONFIG_SCHEMA, -) +from esphome.components.const import CONF_MANUFACTURER +from esphome.components.sendspin import CONF_FIRMWARE_VERSION, CONFIG_SCHEMA from esphome.const import CONF_MODEL, PlatformFramework from tests.component_tests.types import SetCoreConfigCallable From 9f9df85aa00bbac9d4c0db6905e538a80f279834 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:04:48 +0000 Subject: [PATCH 092/759] Bump aioesphomeapi from 46.4.0 to 46.4.1 (#19104) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 72c42dad32f..c73887a39d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.4.0 +aioesphomeapi==46.4.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From ff9b2a1c83edb7b542fa04f93407b86a5c04bcde Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 18:20:40 -0500 Subject: [PATCH 093/759] [remote_receiver] Size the RMT ring buffer from receive symbols by default (#19100) --- .../components/remote_receiver/__init__.py | 14 ++++--- .../remote_receiver/remote_receiver.h | 4 +- .../remote_receiver/remote_receiver_rmt.cpp | 38 +++++++++++-------- .../config/receiver_buffer_size.yaml | 10 +++++ .../config/receiver_esp32_c2.yaml | 12 ++++++ .../config/receiver_esp8266.yaml | 9 +++++ .../remote_receiver/test_buffer_size.py | 28 ++++++++++++++ .../remote_receiver/test_slot_counts.py | 4 +- 8 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp8266.yaml create mode 100644 tests/component_tests/remote_receiver/test_buffer_size.py diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index b2fd87165e4..6eaecf7ab00 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -114,15 +114,18 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, cv.SplitDefault( CONF_BUFFER_SIZE, - esp32="10000b", - esp32_c2="1000b", - esp32_c61="1000b", + esp32=cv.UNDEFINED, + # the pulse ring needs a size; only RMT targets size themselves in setup() + **{ + f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b" + for variant in esp32_rmt.VARIANTS_NO_RMT + }, esp8266="1000b", bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", rp2="1000b", - ): cv.validate_bytes, + ): cv.All(cv.validate_bytes, cv.int_range(min=64)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), @@ -233,7 +236,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_TOLERANCE][CONF_VALUE], config[CONF_TOLERANCE][CONF_TYPE] ) ) - cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + if CONF_BUFFER_SIZE in config: + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_filter_us(config[CONF_FILTER])) cg.add(var.set_idle_us(config[CONF_IDLE])) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index f9ec054fe31..e59a8b25573 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -47,7 +47,7 @@ struct RemoteReceiverComponentStore { /// The position last read from volatile uint32_t buffer_read{0}; bool overflow{false}; - uint32_t buffer_size{1000}; + uint32_t buffer_size{0}; uint32_t receive_size{0}; uint32_t filter_symbols{0}; esp_err_t error{ESP_OK}; @@ -101,7 +101,7 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, HighFrequencyLoopRequester high_freq_; #endif - uint32_t buffer_size_{}; + uint32_t buffer_size_{}; // 0 on RMT targets: sized from receive_symbols in setup() uint32_t filter_us_{10}; uint32_t idle_us_{10000}; }; diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 632ca9763ae..4eebbbb16f1 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -10,6 +10,7 @@ namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; @@ -104,7 +105,11 @@ void RemoteReceiverComponent::setup() { this->store_.config.signal_range_max_ns = this->idle_us_ * 1000; this->store_.filter_symbols = this->filter_symbols_; this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); - this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); + // one slot per pending rmt_receive; two are the floor (one filling while one is decoded), and + // the default of four covers a few frames queued across a stalled loop pass + const uint32_t slot_size = event_size + this->store_.receive_size; + this->store_.buffer_size = + this->buffer_size_ != 0 ? std::max(slot_size * 2, this->buffer_size_) : slot_size * DEFAULT_BUFFER_SLOTS; this->store_.buffer = new uint8_t[this->store_.buffer_size]; error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); @@ -117,20 +122,23 @@ void RemoteReceiverComponent::setup() { } void RemoteReceiverComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Receiver:\n" - " Clock resolution: %" PRIu32 " hz\n" - " RMT symbols: %" PRIu32 "\n" - " Filter symbols: %" PRIu32 "\n" - " Receive symbols: %" PRIu32 "\n" - " Tolerance: %" PRIu32 "%s\n" - " Carrier frequency: %" PRIu32 " hz\n" - " Carrier duty: %u%%\n" - " Filter out pulses shorter than: %" PRIu32 " us\n" - " Signal is done after %" PRIu32 " us of no changes", - this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, - this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", - this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); + ESP_LOGCONFIG( + TAG, + "Remote Receiver:\n" + " Clock resolution: %" PRIu32 " hz\n" + " RMT symbols: %" PRIu32 "\n" + " Filter symbols: %" PRIu32 "\n" + " Receive symbols: %" PRIu32 "\n" + " Buffer size: %" PRIu32 " bytes\n" + " Tolerance: %" PRIu32 "%s\n" + " Carrier frequency: %" PRIu32 " hz\n" + " Carrier duty: %u%%\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", + this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, + this->store_.buffer_size, this->tolerance_, + (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), + this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); if (this->is_failed()) { ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), diff --git a/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml new file mode 100644 index 00000000000..0b334954ebb --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_buffer_size.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_receiver: + - id: rcvr + pin: GPIO4 + buffer_size: 2kb diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml new file mode 100644 index 00000000000..c4497fefd87 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c2.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c2-devkitm-1 + variant: esp32c2 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml new file mode 100644 index 00000000000..f22d00d630f --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp8266.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py new file mode 100644 index 00000000000..9bfd12d9f55 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -0,0 +1,28 @@ +"""buffer_size reaches the receiver when set, and always on the pulse ring targets.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_explicit_buffer_size_is_passed_through( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_buffer_size.yaml")) + assert "rcvr->set_buffer_size(2000);" in main_cpp + + +def test_pulse_ring_target_keeps_a_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_esp8266.yaml")) + assert "rcvr->set_buffer_size(1000);" in main_cpp + + +def test_esp32_variant_without_rmt_keeps_a_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml")) + assert "rcvr->set_buffer_size(1000);" in main_cpp diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index f381a640929..4d69e6d923b 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -27,7 +27,9 @@ def test_bare_receiver_emits_no_counts( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - generate_main(component_config_path("receiver_bare.yaml")) + main_cpp = generate_main(component_config_path("receiver_bare.yaml")) + # the RMT ring is sized in setup() unless buffer_size is set + assert "set_buffer_size" not in main_cpp assert get_define_value("REMOTE_BASE_DUMPER_COUNT") is None assert get_define_value("REMOTE_BASE_LISTENER_COUNT") is None From eecea15f4f714af7dd7278cd2433ee3c800db92f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:42:08 +1000 Subject: [PATCH 094/759] [core][lvgl] Migrate codegen helpers from LVGL to core code (#19105) Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/automation.py | 3 +- esphome/components/lvgl/defines.py | 43 +---------- esphome/components/lvgl/lv_validation.py | 4 +- esphome/components/lvgl/widgets/__init__.py | 3 +- esphome/cpp_generator.py | 39 ++++++++++ tests/unit_tests/test_cpp_generator.py | 79 +++++++++++++++++++++ 6 files changed, 122 insertions(+), 49 deletions(-) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index a62f466413f..c23a36c3892 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -14,7 +14,7 @@ from esphome.const import ( CONF_TIMEOUT, ) from esphome.core import Lambda -from esphome.cpp_generator import TemplateArguments, get_variable +from esphome.cpp_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -30,7 +30,6 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 1eee8041f9a..73fc58736bb 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -157,17 +152,6 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" - - def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -192,31 +176,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their names - # Parameter names come from hardcoded component code (like "x", "it", "event") - # not from user input, so they're safe to use directly - if lamb.parameters and lamb.parameters.parameters: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 42352b96023..6f86e49e511 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +33,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index c9099e3c3a5..a524fe761f9 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression @@ -42,7 +42,6 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, get_styles_used, get_theme_widget_map, get_widget_map, diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e6b8c0de42c..173002438ad 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1187,3 +1187,42 @@ class MockObjClass(MockObj): def __repr__(self): return f"MockObjClass<{str(self.base)}, parents={self._parents}>" + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + +def call_lambda(lamb: LambdaExpression) -> Expression: + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context. + This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + :param lamb: The LambdaExpression to call or reduce + :return: An Expression representing the result of calling the lambda or reducing it to a simple expression + """ + # Developer error if this is called with a lambda that doesn't have a return type + assert lamb.return_type is not None, "Lambda must have a return type to be called" + expr = lamb.content.strip() + if re.match(r"^return\b", expr) and expr.endswith(";"): + # Convert a lambda returning a simple expression to just that expression + expr = RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 81ae586e23b..052513ce979 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -85,6 +85,15 @@ class TestCallExpression: assert actual == 'my_function(1, "2", false)' +class TestStaticCastExpression: + def test_str(self): + target = cg.StaticCastExpression(ct.bool_, 42) + + actual = str(target) + + assert actual == "static_cast(42)" + + class TestStructInitializer: def test_str(self): target = cg.StructInitializer( @@ -229,6 +238,76 @@ class TestLambdaExpression: ) +class TestCallLambda: + """Tests for the call_lambda() function.""" + + def test_call_lambda__return_expression_casts_to_return_type(self): + """A lambda body that is just a return statement reduces to the + expression, cast to the lambda's return type.""" + lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.StaticCastExpression) + assert str(result) == "static_cast(foo + 1)" + + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): + """A class return type is not cast, since static_cast doesn't apply + to arbitrary class types.""" + mock_class = cg.MockObjClass("foo::Bar", parents=()) + lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.RawExpression) + assert str(result) == "get_bar()" + + def test_call_lambda__no_return_with_parameters_calls_with_names(self): + """A multi-statement lambda with parameters is called with the + parameter names as arguments.""" + lamb = cg.LambdaExpression( + ("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_ + ) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == ( + "[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)" + ) + + def test_call_lambda__no_return_type_raises(self): + """Calling a lambda with no declared return type is a developer + error: call_lambda is only for value-returning lambdas.""" + lamb = cg.LambdaExpression(("do_something();",), (), "=") + + with pytest.raises(AssertionError): + cg.call_lambda(lamb) + + def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement( + self, + ): + """A body that merely starts with the substring "return" (e.g. a call + to a function named returnValue()) must not be mistaken for a return + statement -- the match requires a word boundary after "return".""" + lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[=]() -> bool {\n returnValue();\n}()" + + def test_call_lambda__no_return_no_parameters_calls_with_no_args(self): + """A multi-statement lambda without parameters is called with no + arguments.""" + lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[]() -> bool {\n do_something();\n}()" + + class TestLiterals: @pytest.mark.parametrize( "target, expected", From ebb9037ea1bf802334299b7c38b26dac352d028a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 11 Sep 2026 22:08:23 -0500 Subject: [PATCH 095/759] [bridge] New component and `cdc_acm_uart` platform (#11689) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/bridge/__init__.py | 4 + esphome/components/cdc_acm_uart/__init__.py | 1 + .../cdc_acm_uart/bridge/__init__.py | 114 +++++ .../bridge/cdc_acm_uart_bridge.cpp | 468 ++++++++++++++++++ .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 117 +++++ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 34 ++ .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 26 +- script/analyze_component_buses.py | 1 + .../component_tests/cdc_acm_uart/__init__.py | 0 .../component_tests/cdc_acm_uart/test_init.py | 154 ++++++ tests/component_tests/conftest.py | 11 +- tests/component_tests/types.py | 3 +- tests/components/cdc_acm_uart/common.yaml | 18 + .../components/cdc_acm_uart/common_dual.yaml | 12 + .../cdc_acm_uart/test.esp32-p4-idf.yaml | 15 + .../cdc_acm_uart/test.esp32-s2-idf.yaml | 14 + .../cdc_acm_uart/test.esp32-s3-idf.yaml | 17 + 18 files changed, 983 insertions(+), 29 deletions(-) create mode 100644 esphome/components/bridge/__init__.py create mode 100644 esphome/components/cdc_acm_uart/__init__.py create mode 100644 esphome/components/cdc_acm_uart/bridge/__init__.py create mode 100644 esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp create mode 100644 esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h create mode 100644 tests/component_tests/cdc_acm_uart/__init__.py create mode 100644 tests/component_tests/cdc_acm_uart/test_init.py create mode 100644 tests/components/cdc_acm_uart/common.yaml create mode 100644 tests/components/cdc_acm_uart/common_dual.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml create mode 100644 tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f91bc00ae52..246a210c7cb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow @@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke +esphome/components/cdc_acm_uart/* @kbx81 +esphome/components/cdc_acm_uart/bridge/* @kbx81 esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 diff --git a/esphome/components/bridge/__init__.py b/esphome/components/bridge/__init__.py new file mode 100644 index 00000000000..49811b01819 --- /dev/null +++ b/esphome/components/bridge/__init__.py @@ -0,0 +1,4 @@ +CODEOWNERS = ["@kbx81"] +DOMAIN = "bridge" + +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..516af848564 --- /dev/null +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py new file mode 100644 index 00000000000..cee048df5da --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -0,0 +1,114 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, uart, usb_cdc_acm +from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] + +CONF_DTR_PIN = "dtr_pin" +CONF_RTS_PIN = "rts_pin" +CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" + +cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") +CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CDCACMUARTBridge), + cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent), + cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance), + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + } + ).extend(cv.COMPONENT_SCHEMA), + # Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on + # hardware; extend once verified. + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + """Return True if any dict in the subtree has a uart_id entry naming this bus.""" + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_debug(uart_conf: ConfigType) -> ConfigType: + # The worker tasks use the IDF driver directly, so the uart debugger never sees + # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. + if CONF_DEBUG in uart_conf: + raise cv.Invalid( + "A bridged UART cannot use 'debug'; the bridge bypasses the UART " + "component's read/write path.", + [CONF_DEBUG], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + # Bridges of any platform must own their interfaces exclusively; shared ring + # buffers and overwritten callbacks would corrupt both streams silently. The + # seen-set is keyed on the bridge domain so future platforms share it. + # Other components bind either interface through the same uart_id key (the CDC + # instance is itself a uart::UARTComponent) and would race the worker tasks. + # Bare `id:` references (a uart.write action) cannot be distinguished; not caught. + data = full_config.data.setdefault(BRIDGE_DOMAIN, {}) + for conf_key, label in ( + (CONF_UART_ID, "UART"), + (CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"), + ): + owned_id = str(config[conf_key]) + used = data.setdefault(conf_key, set()) + if owned_id in used: + raise cv.Invalid( + f"The {label} '{owned_id}' is already bridged by another 'bridge' " + f"instance; each bridge requires its own {label}.", + [conf_key], + ) + used.add(owned_id) + for domain, domain_conf in full_config.items(): + if domain == BRIDGE_DOMAIN: + continue + if _subtree_references_uart(domain_conf, owned_id): + raise cv.Invalid( + f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " + f"requires exclusive use of its {label}.", + [conf_key], + ) + + fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID]) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + uart_component = await cg.get_variable(config[CONF_UART_ID]) + usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID]) + var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) + await cg.register_component(var, config) + + if dtr_pin_config := config.get(CONF_DTR_PIN): + dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) + cg.add(var.set_dtr_pin(dtr_pin)) + if rts_pin_config := config.get(CONF_RTS_PIN): + rts_pin = await cg.gpio_pin_expression(rts_pin_config) + cg.add(var.set_rts_pin(rts_pin)) diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp new file mode 100644 index 00000000000..042688bfe61 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -0,0 +1,468 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "cdc_acm_uart_bridge.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "driver/uart.h" +#include "soc/soc_caps.h" + +namespace esphome::cdc_acm_uart { + +static const char *const TAG = "cdc_acm_uart"; + +static constexpr size_t UART_TASK_STACK_SIZE = 4096; +static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20; +// Above the default priority but below the USB/Wi-Fi system tasks. +static constexpr UBaseType_t TASK_PRIORITY = 4; + +static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) { + uint32_t now = millis(); + if ((now - *last_ms) >= interval_ms) { + *last_ms = now; + return true; + } + return false; +} + +static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) { + if (len == 0) { + return true; + } + + if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) { + return true; + } + + size_t offset = 0; + while (offset < len) { + size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset); + if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) { + if (should_log_now(log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "USB TX buffer full; some data is lost"); + } + return false; + } + offset += chunk; + } + return true; +} + +void CDCACMUARTBridge::setup() { + // Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is + // handled by configuring the pins inverted, so deasserted idles HIGH. + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(false); + } + + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(false); + } + + // A failed UART never assigned its port number, so the worker tasks would run + // against an indeterminate port. + if (this->uart_parent_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->configured_baud_rate_ = this->uart_parent_->get_baud_rate(); + this->configured_parity_ = this->uart_parent_->get_parity(); + this->configured_stop_bits_ = this->uart_parent_->get_stop_bits(); + this->configured_data_bits_ = this->uart_parent_->get_data_bits(); + + // usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks + // the hub failed, and a failed hub no longer runs loop(), so line coding and line + // state events would never reach this bridge even if its own interface is healthy. + if (this->usb_cdc_parent_->get_parent()->is_failed()) { + ESP_LOGE(TAG, "USB CDC ACM failed; aborting"); + this->mark_failed(); + return; + } + + // Per-instance task names (keyed on the CDC interface number) keep task dumps + // unambiguous with multiple bridges. + char tx_task_name[] = "cdc_uart_tx_0"; + char rx_task_name[] = "cdc_uart_rx_0"; + const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf()); + tx_task_name[sizeof(tx_task_name) - 2] = itf_char; + rx_task_name[sizeof(rx_task_name) - 2] = itf_char; + + xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_); + if (this->uart_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART TX task"); + this->mark_failed(); + return; + } + + xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_); + if (this->uart_rx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART RX task"); + vTaskDelete(this->uart_tx_task_handle_); + this->uart_tx_task_handle_ = nullptr; + this->mark_failed(); + return; + } + + // Only register callbacks once both tasks exist, so a failed setup never drives + // DTR/RTS from a dead bridge. + this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); }); + this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) { + this->host_coding_seen_ = true; + // Another component owns the UART's framing while paused; resume() re-syncs. + if (this->paused_ == 0) { + this->set_line_coding(); + } + }); + + // Release the workers only now: until here a failed setup may still delete the TX + // task, which is safe only while it is parked and owns nothing in the driver. + xTaskNotifyGive(this->uart_tx_task_handle_); + xTaskNotifyGive(this->uart_rx_task_handle_); + + // loop() only services line-coding reloads; stay off the main loop until one is + // scheduled. + this->disable_loop(); +} + +void CDCACMUARTBridge::dump_config() { + ESP_LOGCONFIG(TAG, + "CDC-ACM UART Bridge:\n" + " UART Bus: %u\n" + " USB CDC Interface: %u", + this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf()); + LOG_PIN(" DTR Pin: ", this->dtr_pin_); + LOG_PIN(" RTS Pin: ", this->rts_pin_); +} + +void CDCACMUARTBridge::on_shutdown() { + // The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver, + // freeing the ring buffer and mutexes the worker tasks block on. Suspending the + // tasks unlinks them from those objects first. + if (this->uart_rx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_rx_task_handle_); + } + if (this->uart_tx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_tx_task_handle_); + } +} + +void CDCACMUARTBridge::loop() { + switch (this->state_) { + case MainState::MAIN_STATE_RELOAD_PENDING: + if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) { + return; + } + // Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream + // wants the new framing now, and its own in-flight bytes are its concern. + // apply_settings_live() rewrites the framing registers without reinstalling the + // driver, so the worker tasks blocked inside it are undisturbed. + this->uart_parent_->apply_settings_live(); + this->state_ = MainState::MAIN_STATE_RUNNING; + break; + case MainState::MAIN_STATE_PAUSING: + case MainState::MAIN_STATE_RESUMING: + // Let a host write that was in flight drain, FIFO included, before a reload + // flushes the FIFOs and truncates it. + if (!this->tx_idle_()) { + return; + } + if (this->state_ == MainState::MAIN_STATE_PAUSING) { + this->restore_configured_framing_(); + this->state_ = MainState::MAIN_STATE_PAUSED; + } else { + this->finish_resume_(); + } + break; + default: + break; + } + this->disable_loop(); +} + +void CDCACMUARTBridge::set_line_coding() { + if (!this->sync_host_framing_()) { + return; + } + // Coalesce rapid line-coding updates from the host. + this->reload_requested_at_ = App.get_loop_component_start_time(); + this->state_ = MainState::MAIN_STATE_RELOAD_PENDING; + // Main-loop context (via USBCDCACMInstance::process_events_). + this->enable_loop(); +} + +bool CDCACMUARTBridge::sync_host_framing_() { + // usb_cdc_acm has already translated the wire coding onto the CDC instance (main + // loop); mirror it here so the framing translation has a single source of truth. + bool changed = false; + + // Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate) + // and rates above the SoC ceiling. Anything in between is the driver's call, + // matching what a YAML-configured UART accepts. + const uint32_t baud = this->usb_cdc_parent_->get_baud_rate(); + if (baud == 0 || baud > SOC_UART_BITRATE_MAX) { + ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud, + this->uart_parent_->get_baud_rate()); + } else if (this->uart_parent_->get_baud_rate() != baud) { + this->uart_parent_->set_baud_rate(baud); + changed = true; + } + + const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits(); + if (this->uart_parent_->get_stop_bits() != stop_bits) { + this->uart_parent_->set_stop_bits(stop_bits); + changed = true; + } + + const auto parity = this->usb_cdc_parent_->get_parity(); + if (this->uart_parent_->get_parity() != parity) { + this->uart_parent_->set_parity(parity); + changed = true; + } + + // USB CDC permits data-bit counts the UART cannot represent (up to 16). + const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits(); + if (data_bits < 5 || data_bits > 8) { + ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits, + this->uart_parent_->get_data_bits()); + } else if (this->uart_parent_->get_data_bits() != data_bits) { + this->uart_parent_->set_data_bits(data_bits); + changed = true; + } + + if (changed) { + ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u", + this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(), + this->uart_parent_->get_stop_bits(), static_cast(this->uart_parent_->get_parity())); + } + return changed; +} + +void CDCACMUARTBridge::pause() { + if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) { + return; + } + this->paused_ = 1; + // A null RX task means setup() has not completed (or failed): nothing to stop, and + // the framing snapshot does not exist yet. Should setup() run later, the RX task + // starts parked. + if (this->uart_rx_task_handle_ == nullptr) { + this->state_ = MainState::MAIN_STATE_PAUSED; + return; + } + // Drops a coalesced host reload or a pending resume; loop() restores the framing + // once any host write in flight has drained. + this->state_ = MainState::MAIN_STATE_PAUSING; + this->enable_loop(); +} + +void CDCACMUARTBridge::resume() { + if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) { + return; + } + if (this->uart_rx_task_handle_ == nullptr) { + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + return; + } + // A restore still waiting on the TX side is moot: the host's framing is kept. + if (!this->tx_idle_()) { + this->state_ = MainState::MAIN_STATE_RESUMING; + this->enable_loop(); + return; + } + this->finish_resume_(); + this->disable_loop(); +} + +void CDCACMUARTBridge::finish_resume_() { + // Take the bus back at a known framing before either task runs again: the host's + // if it ever sent one, else the YAML framing (the other owner may have changed it). + if (this->host_coding_seen_) { + this->sync_host_framing_(); + this->uart_parent_->apply_settings_live(); + } else { + this->restore_configured_framing_(); + } + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + this->drive_line_state_(); + xTaskNotifyGive(this->uart_rx_task_handle_); +} + +bool CDCACMUARTBridge::tx_idle_() { + const auto uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK; +} + +void CDCACMUARTBridge::restore_configured_framing_() { + // Always applied: the cached settings can lead the hardware by a pending reload, + // so they are no proof of what is live. + this->uart_parent_->set_baud_rate(this->configured_baud_rate_); + this->uart_parent_->set_parity(this->configured_parity_); + this->uart_parent_->set_stop_bits(this->configured_stop_bits_); + this->uart_parent_->set_data_bits(this->configured_data_bits_); + this->uart_parent_->apply_settings_live(); +} + +void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) { + ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts); + this->host_dtr_ = dtr; + this->host_rts_ = rts; + // Frozen while paused: a host opening the port must not reset a peer that another + // component is talking to. + if (this->paused_ == 0) { + this->drive_line_state_(); + } +} + +void CDCACMUARTBridge::drive_line_state_() { + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->digital_write(this->host_dtr_); + } + if (this->rts_pin_ != nullptr) { + this->rts_pin_->digital_write(this->host_rts_); + } +} + +void CDCACMUARTBridge::uart_rx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_rx_task_(); +} + +void CDCACMUARTBridge::uart_tx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_tx_task_(); +} + +void CDCACMUARTBridge::uart_rx_task_() { + TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle(); + RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + + uint8_t *data = this->uart_rx_buffer_.data(); + const size_t buf_size = this->uart_rx_buffer_.size(); + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + if (this->paused_ != 0) { + // Parked until resume() notifies; nothing is read, so the other owner sees + // every byte. + this->rx_parked_ = 1; + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + this->rx_parked_ = 0; + continue; + } + + // Block until at least one byte is available from UART. + int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS)); + if (total_rx_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", total_rx_size); + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + if (total_rx_size == 0) { + continue; + } + // pause() landed during the read: don't forward a byte to a host that is gone. + if (this->paused_ != 0) { + continue; + } + + // Drain the currently buffered burst without waiting. + while (true) { + int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0); + if (rx_data_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", rx_data_size); + } + break; + } + if (rx_data_size == 0) { + break; + } + ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size); + total_rx_size += rx_data_size; + if (total_rx_size >= (int) buf_size) { + break; + } + } + + ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms); + + ESP_LOGV(TAG, "UART RX: waking up USB TX task"); + xTaskNotifyGive(usb_tx_handle); + } +} + +void CDCACMUARTBridge::uart_tx_task_() { + RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + uint8_t *data_to_uart = this->uart_tx_buffer_.data(); + const size_t buf_size = this->uart_tx_buffer_.size(); + size_t rx_size; + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS; + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + ESP_LOGV(TAG, "Waiting for data to send to UART"); + esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY); + + if (ret != ESP_OK) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "USB RX RingBuf read failed"); + } + // Yield: this task runs above the main loop, so a persistent failure must not + // become a tight loop. + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + // Another component owns the UART; host bytes must not interleave with its traffic. + // tx_busy_ goes up before the check so is_paused() cannot miss a write in flight. + this->tx_busy_ = 1; + if (this->paused_ != 0) { + this->tx_busy_ = 0; + if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size); + } + continue; + } + + ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size); + // Signed: uart_write_bytes() returns -1 on error. + int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size); + this->tx_busy_ = 0; + + if (xfer_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART write failed: %d", xfer_size); + } + } else if (static_cast(xfer_size) != rx_size) { + ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size); + } + } +} + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h new file mode 100644 index 00000000000..64522c86bd5 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -0,0 +1,117 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h" +#include "esphome/core/component.h" + +#include +#include +#include "sdkconfig.h" + +namespace esphome::cdc_acm_uart { + +class CDCACMUARTBridge final : public Component { + public: + // Upper bound on the RX task's blocking read, so pause() takes effect without + // aborting the read. Arriving bytes still unblock it immediately. + static constexpr uint32_t UART_RX_WAIT_MS = 250; + + CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent) + : uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; } + void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + + void set_line_coding(); + void set_line_state(bool dtr, bool rts); + + /** + * Stop forwarding in both directions and hand the UART back to its configured + * framing, so another component may use the bus. Main-loop only. The RX task parks + * within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write + * already in flight is allowed to drain first, which at low baud rates can take + * seconds; the framing is restored only after that, so poll is_paused() rather than + * waiting a fixed interval. Host bytes not yet written to the UART are discarded. + * The DTR/RTS outputs hold their state while paused and follow the host again on + * resume(). + */ + void pause(); + /** + * Re-apply the host's line coding and line state, then resume forwarding. Main-loop + * only. Deferred until any host write still draining has finished, so the reload + * never truncates it. + */ + void resume(); + /// True once both worker tasks are off the bus and the configured framing is restored. + /// With no RX task (setup() failed or has not run) there is nothing to wait for. + bool is_paused() const { + return this->state_ == MainState::MAIN_STATE_PAUSED && + (this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0); + } + + protected: + static void uart_rx_task_fn(void *arg); + static void uart_tx_task_fn(void *arg); + void uart_rx_task_(); + void uart_tx_task_(); + void restore_configured_framing_(); + // True when the TX task has no write in flight and the UART TX FIFO has drained. + bool tx_idle_(); + void finish_resume_(); + void drive_line_state_(); + // Copy the host's line coding onto the UART settings; true if anything changed. + bool sync_host_framing_(); + + TaskHandle_t uart_rx_task_handle_{nullptr}; + TaskHandle_t uart_tx_task_handle_{nullptr}; + + GPIOPin *dtr_pin_{nullptr}; + GPIOPin *rts_pin_{nullptr}; + + uint32_t reload_requested_at_{0}; + + // Worker staging, each sized to the CDC ring buffer it feeds or drains. + std::array uart_rx_buffer_{}; + std::array uart_tx_buffer_{}; + + uart::IDFUARTComponent *uart_parent_; + usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_; + + // YAML framing, captured at setup; the host's line coding overwrites the UART's + // settings, so pause() needs the original to restore. + uint32_t configured_baud_rate_{0}; + uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE}; + uint8_t configured_stop_bits_{0}; + uint8_t configured_data_bits_{0}; + + // Written on the main loop, read by both worker tasks. uint8_t rather than bool: + // GCC on Xtensa emits an out-of-line call for atomic. + std::atomic paused_{0}; + // Raised by the RX task while parked and by the TX task around each UART write, so + // the pause hand-off knows when the bus is actually free. + std::atomic rx_parked_{0}; + std::atomic tx_busy_{0}; + // Main-loop state; paused_ mirrors it for the worker tasks. + enum class MainState : uint8_t { + MAIN_STATE_RUNNING, + MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues + MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing + MAIN_STATE_PAUSED, + MAIN_STATE_RESUMING, // resume() requested while a host write still drains + }; + MainState state_{MainState::MAIN_STATE_RUNNING}; + // Host line state, recorded even while paused so resume() can re-drive the pins. + bool host_dtr_{false}; + bool host_rts_{false}; + // True once the host has sent any line coding; resume() then re-syncs to it. + bool host_coding_seen_{false}; +}; + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index d8eb91586a8..83cb5de89f8 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,15 +7,47 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include +#include #include #include "freertos/ringbuf.h" +#include "esp_err.h" #include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped +// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker +// tasks drain the same ring buffers. +inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t x_ticks_to_wait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + if (*rx_data_size < out_buf_sz) { + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + } + + return ESP_OK; +} + // Callback types for line coding and line state changes using LineCodingCallback = std::function; using LineStateCallback = std::function; @@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented usb_tx_staging_{}; // Non-zero while the TX task holds bytes it has pulled from the ring buffer but not // yet handed to TinyUSB; lets flush() account for data that is in neither the ring // buffer nor TinyUSB's FIFO. diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index e46369660dd..7aa7b46b7b9 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); } -static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t x_ticks_to_wait) { - size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); - - if (buf == nullptr) { - return ESP_FAIL; - } - - memcpy(out_buf, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size = read_sz; - - // Buffer's data can be wrapped, in which case we should perform another read - buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); - if (buf != nullptr) { - memcpy(out_buf + *rx_data_size, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size += read_sz; - } - - return ESP_OK; -} - //============================================================================== // USBCDCACMInstance Implementation //============================================================================== @@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { } void USBCDCACMInstance::usb_tx_task() { - uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + uint8_t *data = this->usb_tx_staging_.data(); size_t tx_data_size = 0; // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs // immediately (unsigned arithmetic keeps this wrap-safe). diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b8ee3066bd2..b805d5155a2 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -81,6 +81,7 @@ ISOLATED_SIGNATURE_PREFIX = "isolated_" # NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", + "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", diff --git a/tests/component_tests/cdc_acm_uart/__init__.py b/tests/component_tests/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/cdc_acm_uart/test_init.py b/tests/component_tests/cdc_acm_uart/test_init.py new file mode 100644 index 00000000000..7bbf163fc32 --- /dev/null +++ b/tests/component_tests/cdc_acm_uart/test_init.py @@ -0,0 +1,154 @@ +"""Tests for the bridge cdc_acm_uart platform's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.cdc_acm_uart import bridge +from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID +from esphome.config import Config +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +_final_validate = bridge._final_validate + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + **kwargs, + ) + + +def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config: + """A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID + pass leaves it, so the debug check can resolve a uart_id to its declaration.""" + uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}] + full = Config() + full["uart"] = uarts + for index, uart_conf in enumerate(uarts): + full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID])) + full.update(domains) + return full + + +def _bridge_config(uart_id: str, cdc_id: str) -> dict: + return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)} + + +def test_accepts_distinct_uart_and_cdc_interfaces( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + _final_validate(_bridge_config("uart_1", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_0", "cdc_acm_2")) + + +def test_rejects_two_bridges_sharing_a_cdc_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config, full_config=_full_config()) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + with pytest.raises(cv.Invalid, match="already bridged"): + _final_validate(_bridge_config("uart_1", "cdc_acm_1")) + + +def test_rejects_uart_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_cdc_interface_shared_with_another_component( + set_core_config: SetCoreConfigCallable, +) -> None: + # The CDC instance is itself a uart::UARTComponent, so other components can bind + # it as a plain UART via uart_id -- that must be rejected just like UART sharing. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_uart_referenced_from_nested_config( + set_core_config: SetCoreConfigCallable, +) -> None: + # References can sit arbitrarily deep, e.g. inside an automation's action list. + _set_esp32_s3( + set_core_config, + full_config=_full_config( + binary_sensor=[ + { + "platform": "gpio", + "on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}], + } + ], + ), + ) + with pytest.raises(cv.Invalid, match="exclusive"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_ignores_other_components_on_other_uarts( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}], + # The bridge domain itself is skipped: this bridge's own entry (and any + # bridge-vs-bridge sharing, which the seen-set already rejects) must not + # trip the exclusivity scan. + bridge=[_bridge_config("uart_0", "cdc_acm_1")], + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_rejects_debug_on_bridged_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + # The bridge talks to the IDF driver directly, so the uart debugger would see + # nothing and its dummy_receiver would steal RX bytes. + _set_esp32_s3( + set_core_config, + full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]), + ) + with pytest.raises(cv.Invalid, match="debug"): + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) + + +def test_allows_debug_on_other_uart( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3( + set_core_config, + full_config=_full_config( + uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}] + ), + ) + _final_validate(_bridge_config("uart_0", "cdc_acm_1")) diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 4f0b786cc28..b5eceeedf67 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -60,7 +60,7 @@ def reset_core() -> Generator[None]: @pytest.fixture(autouse=True) def reset_full_config() -> Generator[None]: """Give each test a clean final-validate config and restore it after.""" - token = final_validate.full_config.set({}) + token = final_validate.full_config.set(Config()) yield final_validate.full_config.reset(token) @@ -75,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: platform, framework = platform_framework.value @@ -94,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]: CORE.data[platform.value] = platform_data config.path_context.set([]) - final_validate.full_config.set(full_config or Config()) + # Production always installs a Config (a FinalValidateConfig), never a plain dict. + if not isinstance(full_config, Config): + full = Config() + full.update(full_config or {}) + full_config = full + final_validate.full_config.set(full_config) yield setter diff --git a/tests/component_tests/types.py b/tests/component_tests/types.py index ee9d3173398..3587517bde0 100644 --- a/tests/component_tests/types.py +++ b/tests/component_tests/types.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Protocol +from esphome.config import Config from esphome.const import PlatformFramework from esphome.types import ConfigType @@ -18,5 +19,5 @@ class SetCoreConfigCallable(Protocol): *, core_data: ConfigType | None = None, platform_data: ConfigType | None = None, - full_config: dict[str, ConfigType] | None = None, + full_config: dict[str, ConfigType] | Config | None = None, ) -> None: ... diff --git a/tests/components/cdc_acm_uart/common.yaml b/tests/components/cdc_acm_uart/common.yaml new file mode 100644 index 00000000000..6c43dfc18b3 --- /dev/null +++ b/tests/components/cdc_acm_uart/common.yaml @@ -0,0 +1,18 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 diff --git a/tests/components/cdc_acm_uart/common_dual.yaml b/tests/components/cdc_acm_uart/common_dual.yaml new file mode 100644 index 00000000000..0ce817fbc20 --- /dev/null +++ b/tests/components/cdc_acm_uart/common_dual.yaml @@ -0,0 +1,12 @@ +# Second UART/CDC pair for a two-bridge setup. Kept out of common.yaml because the +# ESP32-S2 has only two UART controllers and the logger occupies one, so a second +# uart there would fail at runtime. +uart: + - id: uart_1 + tx_pin: 15 + rx_pin: 16 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_2 diff --git a/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..aa9ec8079f2 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-p4-idf.yaml @@ -0,0 +1,15 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + dtr_pin: 20 + rts_pin: 21 diff --git a/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..0beeb80bfa4 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s2-idf.yaml @@ -0,0 +1,14 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + cdc_acm_uart: !include common.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 diff --git a/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..cbb1fc2a3a5 --- /dev/null +++ b/tests/components/cdc_acm_uart/test.esp32-s3-idf.yaml @@ -0,0 +1,17 @@ +packages: + cdc_acm_uart: !include common.yaml + cdc_acm_uart_dual: !include common_dual.yaml + +bridge: + - platform: cdc_acm_uart + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + dtr_pin: 40 + rts_pin: 41 + - platform: cdc_acm_uart + uart_id: uart_1 + usb_cdc_acm_id: cdc_acm_2 + # GPIO19/20 are USB D-/D+ on the S3 (which the CDC side itself uses); use + # unrelated free pins here. + dtr_pin: 17 + rts_pin: 18 From 533002c41e58d48fbc87b65e7c13122996ff5ff2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:12:08 +1000 Subject: [PATCH 096/759] [lvgl] Fix crash when using lvgl.list.add (#19177) --- esphome/components/lvgl/widgets/lv_list.py | 4 ++++ tests/components/lvgl/lvgl-package.yaml | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef99..7711e8bfe4f 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_clear(w: Widget): await _wait_list_triggers_completed() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db356..bd2e77ee8c7 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,18 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + lvgl: id: lvgl_id rotation: 90 @@ -1291,7 +1303,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1347,10 +1359,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id From 5bbfe12e4ff5bc24603f1d9e23c6cade81f2096e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:38:27 +1000 Subject: [PATCH 097/759] [core] Isolate contextvars per task in the coroutine runner (#19238) --- esphome/coroutine.py | 18 ++++++++++-- tests/unit_tests/test_coroutine.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 3ce94cc9791..8a825362270 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -45,6 +45,7 @@ the last `yield` expression defines what is returned. from __future__ import annotations from collections.abc import Awaitable, Callable, Generator, Iterator +import contextvars import enum import functools import heapq @@ -277,14 +278,22 @@ class _Task: id_number: int, iterator: Iterator[None], original_function: Any, + context: contextvars.Context, ): self.priority = priority self.id_number = id_number self.iterator = iterator self.original_function = original_function + self.context = context def with_priority(self, priority: float) -> _Task: - return _Task(priority, self.id_number, self.iterator, self.original_function) + return _Task( + priority, + self.id_number, + self.iterator, + self.original_function, + self.context, + ) @property def _cmp_tuple(self) -> tuple[float, int]: @@ -321,7 +330,10 @@ class FakeEventLoop: coro = coroutine(func) gen = coro(*args, **kwargs) prio = getattr(coro, "priority", 0.0) - task = _Task(prio, self._task_counter, gen, func) + # Each task gets its own copy of the current context, isolating any + # contextvars it sets from other tasks the scheduler interleaves it with + # (mirrors what asyncio.Task does internally). + task = _Task(prio, self._task_counter, gen, func, contextvars.copy_context()) self._task_counter += 1 heapq.heappush(self._pending_tasks, task) @@ -352,7 +364,7 @@ class FakeEventLoop: ) try: - next(task.iterator) + task.context.run(next, task.iterator) # Decrease priority over time, so that if this task is blocked # due to a dependency others will clear the dependency # This could be improved with a less naive approach diff --git a/tests/unit_tests/test_coroutine.py b/tests/unit_tests/test_coroutine.py index e12c273294b..0a8fb59cb81 100644 --- a/tests/unit_tests/test_coroutine.py +++ b/tests/unit_tests/test_coroutine.py @@ -1,5 +1,7 @@ """Tests for the coroutine module.""" +import contextvars + import pytest from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority @@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None: # Check execution order assert execution_order == ["core", "custom", "diagnostics"] + + +def test_context_isolated_between_interleaved_tasks() -> None: + """Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + seen: dict[str, str] = {} + + def task_a(): + my_var.set("a") + yield # suspend so task_b can run before task_a resumes + seen["a"] = my_var.get() + + def task_b(): + my_var.set("b") + yield + seen["b"] = my_var.get() + + loop = FakeEventLoop() + loop.add_job(task_a) + loop.add_job(task_b) + loop.flush_tasks() + + assert seen == {"a": "a", "b": "b"} + + +def test_context_inherits_ambient_value_at_schedule_time() -> None: + """Test that a job sees whatever contextvar value was set before it was scheduled.""" + my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var") + token = my_var.set("ambient") + seen: dict[str, str] = {} + + def task(): + seen["value"] = my_var.get() + yield + + try: + loop = FakeEventLoop() + loop.add_job(task) + loop.flush_tasks() + finally: + my_var.reset(token) + + assert seen == {"value": "ambient"} From eea66fc32b3b971d55573c69939d1885035a2d93 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:06:35 -0500 Subject: [PATCH 098/759] Bump bundled esphome-device-builder to 1.14.8 (#19250) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f500dbe6f4..bdbbe798cea 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 RUN \ platformio settings set enable_telemetry No \ From 6b08aa60e660a238ae710415827fdd7bcd1d8438 Mon Sep 17 00:00:00 2001 From: David Coulson <23066302+davidcoulson@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:25:27 -0400 Subject: [PATCH 099/759] [bluetooth_proxy] Add an advertisement filter hook (#19220) Co-authored-by: Claude Opus 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../components/bluetooth_proxy/__init__.py | 11 +++++++ .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +++++++ .../bluetooth_proxy/bluetooth_proxy.h | 32 +++++++++++++++++++ esphome/core/defines.h | 2 ++ .../test_advertisement_filter.py | 13 ++++++++ ...est-advertisement-filter.esp32-s3-idf.yaml | 12 +++++++ 6 files changed, 82 insertions(+) create mode 100644 tests/component_tests/bluetooth_proxy/test_advertisement_filter.py create mode 100644 tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 1b761849a52..c87ad7f5957 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await _connections_to_code(var, config) +def enable_advertisement_filter() -> None: + """Compile the advertisement filter hook into bluetooth_proxy. + + Called by external filtering components from to_code(). The define behind + this is an implementation detail; do not emit it directly. + + Public API for external components. Do not remove. + """ + cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER") + + async def to_code(config: ConfigType) -> None: if CORE.is_esp32: await _to_code_esp32(config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 878d3cd44e9..cb37057cd4b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + // Ask the filter before the packet is queued, so a dropped advertisement never + // reaches the batch or the network. + if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) { + ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address); + return; + } +#endif + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; adv.address = raw.address; adv.rssi = raw.rssi; @@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set())); +#endif } #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e233c38b567..567109dc60b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556 static_assert(PendingReply{}.empty()); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER +/// Predicate slot letting an external component drop advertisements before they +/// are queued for the API. Same shape as +/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot +/// path, so it must be cheap and must not block. +/// +/// Usage: +/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { +/// return static_cast(self)->should_forward(adv); +/// }}); +/// +/// Returning false drops the advertisement. Not called at all while the API is +/// disconnected, which matters to a stateful filter. Compiled in only when an +/// external component calls bluetooth_proxy.enable_advertisement_filter(). +struct AdvertisementFilter { + void *instance{nullptr}; + bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no filter"; the proxy guards on this. + bool is_set() const { return this->fn != nullptr; } + bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); } +}; +#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + class BluetoothProxy final : public Component { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ @@ -162,6 +185,11 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + /// One subscriber; a later call replaces an earlier one. + void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; } +#endif + uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -330,6 +358,10 @@ class BluetoothProxy final : public Component { // start on an even word, closing two alignment holes. uint32_t last_advertisement_flush_time_{0}; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + AdvertisementFilter advertisement_filter_{}; +#endif + // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6b9b9eda43f..fe06cc3418f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -329,6 +329,8 @@ #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif +// Defined here so static analysis parses the slot and its call site. +#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #endif diff --git a/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py new file mode 100644 index 00000000000..84d8b0677f7 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_advertisement_filter.py @@ -0,0 +1,13 @@ +"""The codegen hook external filtering components use to turn on the filter slot.""" + +from esphome.components import bluetooth_proxy +from esphome.core import CORE + + +def test_enable_advertisement_filter_emits_define() -> None: + """External components call this rather than emitting the define.""" + bluetooth_proxy.enable_advertisement_filter() + + assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in { + define.name for define in CORE.defines + } diff --git a/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f46f4814c2e --- /dev/null +++ b/tests/components/bluetooth_proxy/test-advertisement-filter.esp32-s3-idf.yaml @@ -0,0 +1,12 @@ +# Compile the gated filter path; no external component is in-tree to call +# enable_advertisement_filter(), so the define is forced here. +<<: !include common.yaml + +esphome: + build_flags: + - "-DUSE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" + +esp32_ble_tracker: + +bluetooth_proxy: + active: true From fc8611a2122c77f94861d7b320a30b86654cbe74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:14:26 -0500 Subject: [PATCH 100/759] [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d17ebf235e5..6067fde1642 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.26") + cg.add_library("esphome/noise-c", "0.1.30") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.8") + cg.add_library("esphome/libsodium", "1.10021.11") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 738773d1b56..0e334ac5b4c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.26 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 00f22ca1389..0dce00785bf 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" + assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.26"] + assert libs == ["esphome/noise-c @ 1.0"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.26", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", + "esphome/noise-c @ 1.0", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.26"] + assert cls.calls == ["esphome/noise-c @ 1.0"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.26"] is None + assert compats["esphome/noise-c @ 1.0"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index b03bff19a27..774493ecf41 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 4f0faac1486d03e33f0e18bef2301ea77a3c31db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:16:37 -0500 Subject: [PATCH 101/759] [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) --- esphome/core/helpers.h | 52 ++++++++++++++++++++------ script/cpp_unit_test.py | 3 +- tests/components/core/test_helpers.cpp | 19 ++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124e..987c54a5b03 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -539,7 +541,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +572,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +641,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +758,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab394149..8cb18d08757 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = { def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a3..d6b31508d17 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,23 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + } // namespace esphome::core::testing From ce8fad14359660e46aa999fa7c872a54fa47dea7 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:44:03 +0000 Subject: [PATCH 102/759] Bump bundled esphome-device-builder to 1.14.9 (#19263) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbbe798cea..e00570c8ff2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ From b1bfc512ac1fd41a29570eab641d2d3458de96c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:13 -0500 Subject: [PATCH 103/759] [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 16 ++++-- .../wifi/wifi_component_esp8266.cpp | 6 +- .../wifi/wifi_component_esp_idf.cpp | 57 +++++++++++++++---- .../wifi/wifi_component_libretiny.cpp | 6 +- esphome/core/defines.h | 2 + 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 58803a8cdff..1e57c03b7b0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -626,6 +626,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f9e80995e1b..5ba36143945 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1499,8 +1499,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -2416,7 +2416,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 94fdd9bc142..77a4773a279 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -954,6 +954,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355f..60ec3f9a4d5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d213301..24bf64a99ce 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342a..940f2a07830 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fe06cc3418f..f6010fd7fa0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) From eb1ea4aefe9c6d6abacbec52222cb68ad3ed41c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:23 -0500 Subject: [PATCH 104/759] [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) --- .../bluetooth_connection_bluedroid.cpp | 37 +++++++++++++------ .../bluetooth_connection_bluedroid.h | 1 + esphome/components/esp32_ble/ble.cpp | 35 +++++++++++------- esphome/components/esp32_ble/ble.h | 13 ++++++- .../esp32_ble_client/ble_client_base.cpp | 35 +++++++++++++++++- .../esp32_ble_client/ble_client_base.h | 12 +++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 28 ++++++++++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 ++ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d4..986a67c7a8f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b6..f285260e763 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf82..81fa328c160 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,18 +83,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a46..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda6..88454f7bdbf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd51005..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a324..b4b793b4d0b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +218,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626d..1a424a4a8e8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } From c51020dbaf91051e03e7bbaa50df66eaab4e1ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:56:50 -0500 Subject: [PATCH 105/759] [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) --- esphome/core/helpers.h | 40 ++++++++++++++++++++ tests/components/core/test_helpers.cpp | 52 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 987c54a5b03..b1f24b25a3d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -2123,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2193,6 +2200,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2255,6 +2282,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index d6b31508d17..72af605d61f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -367,4 +367,56 @@ TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { EXPECT_EQ(v.size(), 1u); } +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing From 67871bcf55b47355944e35a11fab96386b3f7e78 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 13 Sep 2026 18:01:58 -0400 Subject: [PATCH 106/759] [i2s_audio][router] Loop thread controls all state changes (#19089) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 ++++++++----- .../router/speaker/router_speaker.cpp | 21 ++++++++++++++++--- .../router/speaker/router_speaker.h | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1382a870465..9feaf39ffff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab0..dd2428e4df4 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906cee..31f3f906301 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); From 16df92212e307096f71ef492b82ba0b16c8daee3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:02:42 -0500 Subject: [PATCH 107/759] [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b4b793b4d0b..e25b6f59fa0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; From ed5a570e1784057770519095d37de0dcec9359eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:14 -0500 Subject: [PATCH 108/759] [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) --- esphome/components/nextion/nextion.cpp | 152 +++++++++--------- esphome/components/nextion/nextion.h | 2 + .../nextion/nextion_component_base.h | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d55..625c915e732 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3f..6c9c8760f8a 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d019201..5e84291b168 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -105,6 +104,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion From bc1841c1b53f33248eb419ff949d469466b4782a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:28 -0500 Subject: [PATCH 109/759] [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++++- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/components/esphome/ota/ota_esphome_noise.cpp | 6 ++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f853ed6a2db..3010df10561 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fcb..68dd0ffb9ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION #ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d0..65476572a1e 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default-init: the frame buffer is written before it is read - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags From cf398ea8b212c55db7b5c70e219f6e589eb527e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:27:03 -0500 Subject: [PATCH 110/759] [core] Resolve file paths against the YAML file that declares them (#19259) --- esphome/config_validation.py | 66 +++++++----- tests/unit_tests/test_config_validation.py | 120 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a38fb2ed82c..1623117a367 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1999,38 +2000,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 4092b4c0d5c..230a8e1f9ef 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3174,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") From e38ee343b407373d1df7849bb1322e66119245f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:35:38 -0500 Subject: [PATCH 111/759] [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) --- .../components/ethernet/w5500_custom_spi.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } From f5afd141de41203c4a4b31465b295d3121c1db8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:37:04 -0500 Subject: [PATCH 112/759] [ota] Allocate the signature block through RAMAllocator (#19251) --- esphome/components/ota/ota_signature_esp_idf.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241d..2192a794410 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; From e431bfcb38f0393521b984de4480dcfa1d8918aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 19:28:24 -0500 Subject: [PATCH 113/759] [spi] Send ESP8266 writes through transferBytes instead of a heap copy (#19265) --- esphome/components/spi/spi_arduino.cpp | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 14428bed629..ae2d2906edf 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -44,16 +44,8 @@ class SPIDelegateHw : public SPIDelegate { #ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) - // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be - // so we need to copy the data to a temporary buffer - if (reinterpret_cast(ptr) & 0x3) { - ESP_LOGVV(TAG, "SPI write buffer not word aligned, copying to temporary buffer"); - auto txbuf = std::vector(length); - memcpy(txbuf.data(), ptr, length); - this->channel_->writeBytes(txbuf.data(), length); - } else { - this->channel_->writeBytes(ptr, length); - } + // writeBytes() needs a word aligned pointer; transferBytes() bounces unaligned chunks through a stack buffer + this->channel_->transferBytes(ptr, nullptr, length); #else this->channel_->writeBytes(ptr, length); #endif From 7801cf4a8ac103a88e64c9855ae2560c2f5b9648 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:13 +1200 Subject: [PATCH 114/759] [core] Clear loaded_platforms on CORE.reset() (#19268) --- esphome/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22f..5fcad90a81a 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} From 91b1a82a66aa30ed9a7c6c3f8fc54d9c11266dc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 20:03:00 -0500 Subject: [PATCH 115/759] [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) --- esphome/components/api/__init__.py | 5 +- esphome/components/api/api_buffer.cpp | 35 +- esphome/components/api/api_buffer.h | 24 +- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 3 + .../components/api/api_frame_helper_noise.cpp | 20 +- .../components/api/api_overflow_buffer.cpp | 121 ++--- esphome/components/api/api_overflow_buffer.h | 93 ++-- tests/components/api/__init__.py | 17 + tests/components/api/test_api_buffer.cpp | 65 +++ tests/components/api/test_overflow_buffer.cpp | 510 ++++++++++++++++++ 12 files changed, 755 insertions(+), 145 deletions(-) create mode 100644 tests/components/api/__init__.py create mode 100644 tests/components/api/test_api_buffer.cpp create mode 100644 tests/components/api/test_overflow_buffer.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bfc..272b0786905 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971f..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe587..7caa68aa4d5 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d910f6fc67a..749eaeb3929 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a189..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c0..a68a0ad0d87 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee8..400cd1d9b86 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba8..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281a..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 00000000000..2aa558726c3 --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.core import CORE +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + CORE.register_controller() # api_server registers with the controller registry + + manifest.to_code = to_code_testing diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 00000000000..c54780050e3 --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 00000000000..4b27e544963 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST From ccec6e72bfbcb8ff498a3467840e591662885958 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 9 Sep 2026 14:22:23 +0200 Subject: [PATCH 116/759] [sendspin] Fix codec enum codegen when codecs is not set (#19055) --- esphome/components/sendspin/__init__.py | 2 +- tests/components/sendspin/common-media_source.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 8ef11a7f909..c1970ab1325 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -307,7 +307,7 @@ async def to_code(config: ConfigType) -> None: player_cfg = data.player_config sample_rate = player_cfg[CONF_SAMPLE_RATE] - codecs = player_cfg[CONF_CODECS] + codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 0c136fbd43d..1977b79c04d 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,4 +9,3 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal - codecs: [pcm, opus, flac] From ad4ee1d34e957cb867a291c24fcb20824031471e Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 10 Sep 2026 06:06:44 +0200 Subject: [PATCH 117/759] [network] Improve `network::is_connected()` to better handle multiple interfaces (#18999) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/network/util.h | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 65a578c22ff..57c5a66833b 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -26,30 +26,34 @@ namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) ESPHOME_ALWAYS_INLINE inline bool is_connected() { + // With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which + // clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked. + // NOLINTBEGIN(readability-simplify-boolean-expr) #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected()) return true; #endif #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_connected(); + if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected()) + return true; #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_connected(); + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) + return true; #endif #ifdef USE_OPENTHREAD - if (openthread::global_openthread_component != nullptr) - return openthread::global_openthread_component->is_connected(); + if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) + return true; #endif #ifdef USE_HOST return true; // Assume it's connected #endif return false; + // NOLINTEND(readability-simplify-boolean-expr) } /// Return whether the network is disabled: every configured interface with a From 6bd6603d523160dfa2a7f0ec0b997cc606c498de Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:22:30 +0000 Subject: [PATCH 118/759] Bump bundled esphome-device-builder to 1.14.6 (#19072) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ac84ee4689f..cfa47fbdad2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 RUN \ platformio settings set enable_telemetry No \ From 3b499ecb3e538c0f5bb20a4f166053004147840c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Sep 2026 03:20:30 -0500 Subject: [PATCH 119/759] [core] Support set_internal() during setup, log error after setup (#19069) --- esphome/core/entity_base.cpp | 9 ++++ esphome/core/entity_base.h | 27 ++++++++---- .../fixtures/set_internal_at_boot.yaml | 34 +++++++++++++++ .../integration/test_set_internal_at_boot.py | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixtures/set_internal_at_boot.yaml create mode 100644 tests/integration/test_set_internal_at_boot.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 21a5fc3706c..dc27c1e56a2 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -56,6 +56,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui this->flags_.entity_category = (entity_fields >> ENTITY_FIELD_ENTITY_CATEGORY_SHIFT) & 0x3; } +void EntityBase::set_internal(bool internal) { + // Remove the after-setup path in 2027.3.0 and ignore the call instead. + if (App.is_setup_complete()) { + ESP_LOGE(TAG, "'%s': set_internal() after setup is undefined behavior, stops working in 2027.3.0", + this->get_name().c_str()); + } + this->flags_.internal = internal; +} + // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index f38e30bf52d..8796e9f067a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -88,13 +88,26 @@ class EntityBase { // Get whether this Entity should be hidden outside ESPHome bool is_internal() const { return this->flags_.internal; } - // Deprecated: Calling set_internal() at runtime is undefined behavior. Components and clients - // are NOT notified of the change, the flag may have already been read during setup, and there - // is NO guarantee any consumer will observe the new value. Use the 'internal:' YAML key instead. - ESPDEPRECATED("set_internal() is undefined behavior at runtime — components and Home Assistant are NOT " - "notified. Use the 'internal:' YAML key instead. Will be removed in 2027.3.0.", - "2026.3.0") - void set_internal(bool internal) { this->flags_.internal = internal; } + // Set whether this Entity should be hidden outside ESPHome. Prefer the 'internal:' YAML key + // whenever possible: it is guaranteed and has none of the limitations below. Use this only when + // the decision can only be made at boot. Must be called before MQTT and the API read the flag: + // from on_boot at the default priority, or a setup() that runs above setup_priority::AFTER_WIFI. + // If the answer comes from a device handshake, hold setup with can_proceed() until it arrives. + // Calls after setup finishes are undefined behavior: the flag is still written and an error is + // logged, and from 2027.3.0 the call will be ignored. + // + // Known limitations. Not bugs, so no issue reports please; a PR that removes one with no RAM + // or performance cost would be considered. + // - No consumer is notified of a change, so the flag can only be decided once per boot. + // - The guard is coarse: a call from a priority below AFTER_WIFI (an on_boot with a low priority, + // or a setup() at LATE) still passes, but the API camera listener is already registered, MQTT + // (AFTER_CONNECTION) has cached the flag, and an API client that connected while setup was + // stalled on a slow component has already listed the entities, so they keep the old value. + // - Un-hiding an entity declared 'internal: true' in YAML skips the duplicate name check that + // codegen runs for exposed entities, so a name collision can surface at runtime. Entities with + // only an 'id:' are forced internal and use the id as their name. + // - Zigbee codegen skips YAML internal entities entirely, so un-hiding cannot add them to Zigbee. + void set_internal(bool internal); // Check if this object is declared to be disabled by default. // That means that when the device gets added to Home Assistant (or other clients) it should diff --git a/tests/integration/fixtures/set_internal_at_boot.yaml b/tests/integration/fixtures/set_internal_at_boot.yaml new file mode 100644 index 00000000000..b3007e9dbda --- /dev/null +++ b/tests/integration/fixtures/set_internal_at_boot.yaml @@ -0,0 +1,34 @@ +esphome: + name: set-internal-at-boot + on_boot: + then: + - lambda: |- + id(hidden_at_boot).set_internal(true); + id(shown_at_boot).set_internal(false); + +host: + +api: + actions: + - action: set_internal_late + then: + - lambda: id(untouched).set_internal(true); + +logger: + +sensor: + - platform: template + name: "Hidden At Boot" + id: hidden_at_boot + lambda: return 1.0; + + - platform: template + name: "Shown At Boot" + id: shown_at_boot + internal: true + lambda: return 2.0; + + - platform: template + name: "Untouched" + id: untouched + lambda: return 3.0; diff --git a/tests/integration/test_set_internal_at_boot.py b/tests/integration/test_set_internal_at_boot.py new file mode 100644 index 00000000000..68b0bd10802 --- /dev/null +++ b/tests/integration/test_set_internal_at_boot.py @@ -0,0 +1,41 @@ +"""Integration test for set_internal() called during and after setup.""" + +from __future__ import annotations + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_set_internal_at_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """set_internal() in on_boot changes API exposure, later calls log an error.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + names = {entity.name for entity in entities} + + assert "Hidden At Boot" not in names + assert "Shown At Boot" in names + assert "Untouched" in names + + late = next(s for s in services if s.name == "set_internal_late") + await client.execute_service(late, {}) + await waiter.wait_for( + "'Untouched'", + "set_internal() after setup is undefined behavior", + timeout=5.0, + ) + + # Still written during the deprecation window, ignored from 2027.3.0 + entities, _ = await client.list_entities_services() + assert "Untouched" not in {entity.name for entity in entities} From 1975b17eac18d1d03778a753835d48c71a58dda9 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:25:41 -0500 Subject: [PATCH 120/759] Bump bundled esphome-device-builder to 1.14.7 (#19096) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index cfa47fbdad2..6f500dbe6f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 RUN \ platformio settings set enable_telemetry No \ From 6cc2b9bf1740a1cc004050b5c6e6be48e55ea65a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:12:08 +1000 Subject: [PATCH 121/759] [lvgl] Fix crash when using lvgl.list.add (#19177) --- esphome/components/lvgl/widgets/lv_list.py | 4 ++++ tests/components/lvgl/lvgl-package.yaml | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 83cbfb5ef99..7711e8bfe4f 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -227,6 +227,7 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -370,6 +371,7 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -503,6 +505,7 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -536,6 +539,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) + await _wait_list_triggers_completed() async def do_clear(w: Widget): await _wait_list_triggers_completed() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 07c492db356..bd2e77ee8c7 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -30,6 +30,18 @@ binary_sensor: widget: button_button state: pressed +globals: + - id: counter + type: int + +script: + - id: add_row + then: + - lvgl.list.add: + id: test_list_id + label: + text: row + lvgl: id: lvgl_id rotation: 90 @@ -1291,7 +1303,7 @@ lvgl: then: - logger.log: format: "table selected row %u col %u" - args: [row, column] + args: [(unsigned)row, (unsigned)column] on_click: then: - lvgl.table.cell.update: @@ -1347,10 +1359,12 @@ lvgl: - logger.log: format: "list entry added at %d" args: [list_index] + - lambda: "id(counter)++;" on_remove: - logger.log: format: "list entry removed at %d" args: [list_index] + - lambda: "id(counter)--;" on_click: - lvgl.list.add_text: id: test_list_id From 3eda3060b8f938e231cc1b2a63e7b8e07f6f381f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:06:35 -0500 Subject: [PATCH 122/759] Bump bundled esphome-device-builder to 1.14.8 (#19250) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6f500dbe6f4..bdbbe798cea 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.7 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 RUN \ platformio settings set enable_telemetry No \ From 9814966fe7a8f48b29dac6d3f2a50bb681abe534 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:14:26 -0500 Subject: [PATCH 123/759] [noise] Bump noise-c to 0.1.30 and libsodium to 1.10021.11 (#19062) --- esphome/components/noise/__init__.py | 4 +-- platformio.ini | 6 ++-- tests/script/test_platformio_install_deps.py | 34 ++++++++++---------- tests/unit_tests/test_platformio_prefetch.py | 4 +-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d17ebf235e5..6067fde1642 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.26") + cg.add_library("esphome/noise-c", "0.1.30") # noise-c depends on libsodium, but declaring it here too lets the # library manager see the full set up front instead of discovering # libsodium only after noise-c has downloaded, so the two can download # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.8") + cg.add_library("esphome/libsodium", "1.10021.11") # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/platformio.ini b/platformio.ini index 738773d1b56..0e334ac5b4c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) improv/Improv@1.2.7 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.26 ; noise (api, ota) + esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.26 ; used by noise (api, ota) + esphome/noise-c@0.1.30 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py index 00f22ca1389..0dce00785bf 100644 --- a/tests/script/test_platformio_install_deps.py +++ b/tests/script/test_platformio_install_deps.py @@ -35,8 +35,8 @@ def _load_script(): def test_spec_key_collapses_destinations() -> None: """Two specs delivering one package share a directory and one key.""" mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" - assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" + assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" + assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( "esp32async/asynctcp @ 3.5.0" ) @@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None: "[env:a]\n" "platform = fake/platform@1\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" " ${common.lib_deps}\n" " internal_lib\n" "[env:b]\n" "lib_deps =\n" - " esphome/noise-c @ 0.1.26\n" + " esphome/noise-c @ 1.0\n" ) mod = _load_script() args = Namespace(libraries=True, platforms=True, tools=False) libs, platforms, tools = mod.parse_specs(str(ini), args) # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 0.1.26"] + assert libs == ["esphome/noise-c @ 1.0"] assert platforms == ["fake/platform@1"] assert tools == [] assert mod.build_cli_args(libs, platforms, tools) == [ "-l", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", "-p", "fake/platform@1", ] @@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None: mod.parallel_install( cls, [ - "esphome/noise-c @ 0.1.26", - "esphome/noise-c @ 0.1.26", + "esphome/noise-c @ 1.0", + "esphome/noise-c @ 1.0", "esphome/already @ 1.0", "https://x/framework.tar.xz", ], ) - assert cls.calls == ["esphome/noise-c @ 0.1.26"] + assert cls.calls == ["esphome/noise-c @ 1.0"] assert cls.lock_events == ["lock", "unlock"] @@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"name": "SPI"}, ], @@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) assert len(cls.calls) == 3 # the shared dep installs exactly once assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} # Wave-1 strings carry no compatibility; the dependency wave does compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 0.1.26"] is None + assert compats["esphome/noise-c @ 1.0"] is None dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) assert dep_compat is not None # mirrors pio's install_dependency @@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: mod = _load_script() cls = _reset_fake(str(tmp_path)) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"name": "vendored", "version": "https://github.com/x/y.git"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} @@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: """Already-installed top-level packages still feed the dependency wave; a warm store can be missing a transitive dep.""" mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) + cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) cls.deps = { - "esphome/noise-c @ 0.1.26": [ + "esphome/noise-c @ 1.0": [ {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, ], } - mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) + mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index b03bff19a27..774493ecf41 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None: {"name": "SPI"}, ] m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out # The dep wave carries its compatibility so _install searches qualified dep_call = m._install.call_args_list[-1] @@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None: m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( installed.append(getattr(spec, "name", str(spec))) ) - pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) + pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))]) assert installed == ["noise-c"] From 0803d7b37ce8c0e52960ca43180cb2ad30d4c7a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:16:37 -0500 Subject: [PATCH 124/759] [core] Add FixedVector::try_init so callers can handle an exhausted heap (#19253) --- esphome/core/helpers.h | 52 ++++++++++++++++++++------ script/cpp_unit_test.py | 3 +- tests/components/core/test_helpers.cpp | 19 ++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a0afb03124e..987c54a5b03 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #endif #ifdef USE_ESP32 +#include #include #endif @@ -539,7 +541,15 @@ template inline void init_array_from(std::array &des } } -/// Fixed-capacity vector - allocates once at runtime, never reallocates +// Abort with a reason that reaches the panic output on ESP32. Elsewhere the literal is dropped +// before it can land in rodata, which is RAM on ESP8266 +#ifdef USE_ESP32 +#define ESPHOME_ABORT_WITH_REASON(reason) esp_system_abort(reason) +#else +#define ESPHOME_ABORT_WITH_REASON(reason) abort() +#endif + +/// Fixed-capacity vector - sized once through init() or try_init(); push_back never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time template class FixedVector { @@ -562,8 +572,7 @@ template class FixedVector { void cleanup_() { if (data_ != nullptr) { destroy_elements_(); - // Free raw memory - ::operator delete(data_); + free(data_); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } } @@ -632,16 +641,27 @@ template class FixedVector { // Allocate capacity - can be called multiple times to reinit // IMPORTANT: After calling init(), you MUST use push_back() to add elements. // Direct assignment via operator[] does NOT update the size counter. + // Aborts on exhaustion; use try_init() to handle failure. void init(size_t n) { + if (!try_init(n)) + ESPHOME_ABORT_WITH_REASON("FixedVector: out of memory"); + } + + // Same as init(), but returns false when memory is exhausted; the previous storage is freed either way + bool try_init(size_t n) { cleanup_(); reset_(); - if (n > 0) { - // Allocate raw memory without calling constructors - // sizeof(T) is correct here for any type T (value types, pointers, etc.) - // NOLINTNEXTLINE(bugprone-sizeof-expression) - data_ = static_cast(::operator new(n * sizeof(T))); - capacity_ = n; - } + if (n == 0) + return true; + if (n > SIZE_MAX / sizeof(T)) + return false; // the byte count would wrap into a small block + // sizeof(T) is correct here for any type T (value types, pointers, etc.) + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + data_ = static_cast(malloc(n * sizeof(T))); + if (data_ == nullptr) + return false; + capacity_ = n; + return true; } // Clear the vector (destroy all elements, reset size to 0, keep capacity) @@ -738,14 +758,22 @@ template class FixedVector { template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { + static_assert(std::is_trivially_default_constructible_v && std::is_trivially_destructible_v, + "the heap fallback leaves elements unconstructed"); if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new T[size]; + if (size <= SIZE_MAX / sizeof(T)) { + // NOLINTNEXTLINE(bugprone-sizeof-expression,cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory) + this->heap_buffer_ = static_cast(malloc(size * sizeof(T))); + } + // Callers write through get() unchecked, so exhaustion aborts like the new[] it replaces + if (this->heap_buffer_ == nullptr) + ESPHOME_ABORT_WITH_REASON("SmallBufferWithHeapFallback: out of memory"); this->buffer_ = this->heap_buffer_; } } - ~SmallBufferWithHeapFallback() { delete[] this->heap_buffer_; } + ~SmallBufferWithHeapFallback() { free(this->heap_buffer_); } // NOLINT(cppcoreguidelines-no-malloc) // Delete copy and move operations to prevent double-delete SmallBufferWithHeapFallback(const SmallBufferWithHeapFallback &) = delete; diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index f8bab394149..8cb18d08757 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -36,7 +36,8 @@ PLATFORMIO_OPTIONS = { def run_tests(selected_components: list[str]) -> int: - os.environ["ASAN_OPTIONS"] = "detect_leaks=0" + # allocator_may_return_null: an oversized request must come back empty, not abort the run + os.environ["ASAN_OPTIONS"] = "detect_leaks=0:allocator_may_return_null=1" return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index baf688fc8a3..d6b31508d17 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -348,4 +348,23 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) { EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0); } +// --- FixedVector::try_init() --- + +// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check +static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); } + +TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { + FixedVector v; + const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t)); + escape(&v); + EXPECT_FALSE(ok); + EXPECT_EQ(v.capacity(), 0u); + EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap + EXPECT_EQ(v.capacity(), 0u); + EXPECT_TRUE(v.try_init(0)); + EXPECT_TRUE(v.try_init(4)); + v.push_back(7); + EXPECT_EQ(v.size(), 1u); +} + } // namespace esphome::core::testing From 9064bfcc85fc99cd2403dc109c308c9782295784 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:44:03 +0000 Subject: [PATCH 125/759] Bump bundled esphome-device-builder to 1.14.9 (#19263) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbbe798cea..e00570c8ff2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.8 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ From b17cd89469498a698cf68f441dc5dcc09c3fce15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:13 -0500 Subject: [PATCH 126/759] [wifi] Drop a scan instead of aborting when its results cannot be allocated, filter ESP32 scans by SSID in the driver (#19254) --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 16 ++++-- .../wifi/wifi_component_esp8266.cpp | 6 +- .../wifi/wifi_component_esp_idf.cpp | 57 +++++++++++++++---- .../wifi/wifi_component_libretiny.cpp | 6 +- esphome/core/defines.h | 2 + 7 files changed, 74 insertions(+), 22 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b8c6d774ac5..d4b39c029b7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -623,6 +623,9 @@ async def to_code(config): networks = config.get(CONF_NETWORKS, []) if networks: cg.add(var.init_sta(len(networks))) + if len(networks) > 1: + # The ESP32 scan can filter one SSID in the driver; with several the whole list is kept + cg.add_define("USE_WIFI_MULTI_SSID") def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 694e6164769..f290832a184 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1498,8 +1498,8 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->has_completed_scan_after_captive_portal_start_ = - true; // Track that we've done a scan since captive portal started + // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan + this->has_completed_scan_after_captive_portal_start_ = !this->is_scan_driver_filtered_(); this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { @@ -2415,7 +2415,7 @@ void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { ScanResultsLock lock(this); -#if defined(USE_RP2) || defined(USE_ESP32) +#if defined(USE_RP2) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 63df9fbfa51..16b62a5bb0e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -178,12 +178,12 @@ struct EAPAuth { using bssid_t = std::array; -/// Initial reserve size for filtered scan results (typical: 1-3 matching networks per SSID) -static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; +// ESP32 with one configured network: the driver filters the scan by its SSID and only this many of +// its BSSIDs are kept, the strongest ones +static constexpr size_t WIFI_SCAN_RESULT_BOUND = 12; -// Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) -// Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2) || defined(USE_ESP32) +// RP2040's callback delivers results one at a time with no count, so it needs a growable vector +#if defined(USE_RP2) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -948,6 +948,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#if defined(USE_ESP32) && !defined(USE_WIFI_MULTI_SSID) + bool scan_driver_filtered_{false}; + bool is_scan_driver_filtered_() const { return this->scan_driver_filtered_; } +#else + constexpr bool is_scan_driver_filtered_() const { return false; } +#endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) // Platform-specific STA state enum, defined in platform cpp file. // On ESP8266, written from SDK system context (wifi_event_callback) — diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 031da1b355f..60ec3f9a4d5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -773,7 +773,11 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + this->scan_done_ = true; + return; + } // Second pass: store matching networks for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ce75d213301..24bf64a99ce 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -909,7 +909,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); uint16_t number = it.number; - bool needs_full = this->needs_full_scan_results_(); + const bool filtered = this->is_scan_driver_filtered_(); + const bool needs_full = this->needs_full_scan_results_(); { // Mutate in place under the lock; blocking a portal request is fine and // avoids scratch buffers @@ -926,8 +927,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } - // Smart reserve: full capacity if needed, small reserve otherwise - this->scan_result_.reserve(needs_full ? number : WIFI_SCAN_RESULT_FILTERED_RESERVE); + const size_t wanted = filtered ? std::min(number, WIFI_SCAN_RESULT_BOUND) : number; + // Storage is reused across the scans of one retry cycle and freed on connect; an exhausted + // heap drops this scan and the retry logic scans again + if (this->scan_result_.capacity() < wanted && !this->scan_result_.try_init(wanted)) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "No memory for %zu scan results", wanted); + return; + } #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor @@ -955,22 +962,38 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } #endif // USE_ESP32_HOSTED - // Check C string first - avoid std::string construction for non-matching networks const char *ssid_cstr = reinterpret_cast(record.ssid); - - // Only construct std::string and store if needed - if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { - bssid_t bssid; - std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (!needs_full && !this->matches_configured_network_(ssid_cstr, record.bssid)) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + bssid_t bssid; + std::copy(record.bssid, record.bssid + 6, bssid.begin()); + if (this->scan_result_.size() < wanted) { this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); - } else { - this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; } + // Records arrive in scan order, not by signal, so a bounded store keeps the strongest by + // replacing its weakest entry. Only SSID and signal decide here; a channel or auth constrained + // network hidden behind 12 stronger APs of its own SSID is not a real deployment + WiFiScanResult *weakest = &this->scan_result_[0]; + for (auto &res : this->scan_result_) { + if (res.get_rssi() < weakest->get_rssi()) + weakest = &res; + } + if (record.rssi <= weakest->get_rssi()) { + this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); + continue; + } + // Rebuilt in place rather than assigned; assignment pulls in CompactString's operators, 104 B of flash + weakest->~WiFiScanResult(); + new (weakest) WiFiScanResult(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, + record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } } ESP_LOGV(TAG, "Scan complete: %u found, %zu stored%s", number, this->scan_result_.size(), - needs_full ? "" : " (filtered)"); + filtered ? LOG_STR_LITERAL(" (driver filtered)") : LOG_STR_LITERAL("")); #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS this->notify_scan_results_listeners_(); #endif @@ -1047,6 +1070,16 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { wifi_scan_config_t config{}; config.ssid = nullptr; config.bssid = nullptr; +#ifndef USE_WIFI_MULTI_SSID + // One configured network with an SSID: let the driver keep only its APs, so the WiFi library + // holds fewer records during the scan. Full results (portal, provisioning, listeners) and a + // network configured by BSSID alone still scan everything + this->scan_driver_filtered_ = + !this->needs_full_scan_results_() && this->sta_.size() == 1 && !this->sta_[0].get_ssid().empty(); + if (this->scan_driver_filtered_) { + config.ssid = const_cast(reinterpret_cast(this->sta_[0].get_ssid().c_str())); + } +#endif config.channel = 0; config.show_hidden = true; config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 63a63e7342a..940f2a07830 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -709,7 +709,11 @@ void WiFiComponent::wifi_scan_done_callback_() { } } - this->scan_result_.init(count); // Exact allocation + if (!this->scan_result_.try_init(count)) { + ESP_LOGW(TAG, "No memory for %zu scan results", count); + WiFi.scanDelete(); + return; + } // Second pass: store matching networks for (int i = 0; i < num; i++) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eaece6d5ffa..b78516c6ef8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -260,6 +260,8 @@ #ifdef USE_ARDUINO #define USE_PROMETHEUS #define USE_WIFI_WPA2_EAP +// Kept in the Arduino block so clang-tidy sees both scan storage paths +#define USE_WIFI_MULTI_SSID #endif // Platforms with native 64-bit time sources (no rollover tracking needed) From 1a555d58489a4c4e8e1bb30e13a5e8e30285e59f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:49:23 -0500 Subject: [PATCH 127/759] [esp32_ble_tracker] Re-register GATT clients after ble.disable and ble.enable (#19068) --- .../bluetooth_connection_bluedroid.cpp | 37 +++++++++++++------ .../bluetooth_connection_bluedroid.h | 1 + esphome/components/esp32_ble/ble.cpp | 35 +++++++++++------- esphome/components/esp32_ble/ble.h | 13 ++++++- .../esp32_ble_client/ble_client_base.cpp | 35 +++++++++++++++++- .../esp32_ble_client/ble_client_base.h | 12 +++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 28 ++++++++++++-- .../esp32_ble_tracker/esp32_ble_tracker.h | 3 ++ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 15f854239d4..986a67c7a8f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -45,15 +45,7 @@ void BluedroidGattClient::setup() { void BluedroidGattClient::loop() { if (!esp32_ble::global_ble->is_active()) { - // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer - // frees its slot, then re-register the app on the next enable. - auto down_st = this->state(); - if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { - this->release_services(); - this->set_idle_(); - this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); - } - this->set_state(ClientState::INIT); + // ble_before_disabled_event_handler() settles the slot. return; } auto st = this->state(); @@ -65,7 +57,7 @@ void BluedroidGattClient::loop() { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } - // Do not wait for REG_EVT; a dropped event must not wedge the slot. + // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled @@ -78,8 +70,8 @@ void BluedroidGattClient::loop() { this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); } } else { - // The loop stays on while a link exists (stack-down watch, pre-started - // search flush); it settles only back at IDLE. + // The loop stays on while a link exists (pre-started search flush); it + // settles only back at IDLE. this->deliver_pending_search_(); if (this->state() == ClientState::IDLE) { this->disable_loop(); @@ -87,6 +79,22 @@ void BluedroidGattClient::loop() { } } +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); if (this->is_failed()) { @@ -97,6 +105,11 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } // Only from idle: clobbering DISCONNECTING would open a new link the // stale CLOSE_EVT then tears down. if (this->state() != ClientState::IDLE) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0d0b4fed5b6..f285260e763 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -56,6 +56,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; void connect() override; void disconnect() override; + void ble_before_disabled_event_handler() override; bool wants_parsed_advertisements() override { return false; } void on_scan_end() override {} bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fc95760cf82..81fa328c160 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,18 +83,23 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING @@ -580,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 7d2d0438a46..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,8 +102,8 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; @@ -176,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e6cdde9cda6..88454f7bdbf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -42,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -72,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -93,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -117,6 +136,15 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // A registration whose event never arrived must not block this connection's release. @@ -199,7 +227,10 @@ void BLEClientBase::release_services() { #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; - esp_ble_gattc_cache_clean(this->remote_bda_); + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + esp_ble_gattc_cache_clean(this->remote_bda_); + } #endif } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index e4b9cd51005..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,6 +41,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; void unconditional_disconnect(); void release_services(); @@ -114,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -139,7 +140,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + // Set by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it bool services_released_{false}; // 8 bytes used, no padding @@ -155,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); /// Hook called once a connection has been fully torn down (after release_services() and - /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) - /// override this to release that state. `reason` is the controller reason code, or - /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5339565a324..b4b793b4d0b 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -74,11 +74,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -218,7 +218,27 @@ void ESP32BLETracker::stop_scan() { this->stop_scan_(); } -void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } +void ESP32BLETracker::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 618444e626d..1a424a4a8e8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -113,6 +113,9 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } From cd5d4ff25422e7a0142e73095f50c5a29f1a03fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 16:56:50 -0500 Subject: [PATCH 128/759] [core] Add RAMAllocator::make_unique for objects whose allocation may fail (#19245) --- esphome/core/helpers.h | 40 ++++++++++++++++++++ tests/components/core/test_helpers.cpp | 52 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 987c54a5b03..b1f24b25a3d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -2123,6 +2126,10 @@ void delay_microseconds_safe(uint32_t us); /// @name Memory management ///@{ +template struct RAMDeleter; +/// unique_ptr over RAMAllocator storage +template using RAMUniquePtr = std::unique_ptr>; + /** An STL allocator that uses SPI or internal RAM. * Returns `nullptr` in case no memory is available. * @@ -2193,6 +2200,26 @@ template class RAMAllocator { free(p); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) } + /// Value initialize one T; empty on exhaustion. new (std::nothrow) aborts on ESP-IDF instead. + /// Default flags prefer PSRAM; pass PREFER_INTERNAL to keep an object where plain new put it. + template RAMUniquePtr make_unique(Args &&...args) { + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + T *p = this->allocate(1); + if (p == nullptr) + return {}; + // ::new so a class scoped operator new cannot hide the global placement form + return RAMUniquePtr(::new (p) T(std::forward(args)...)); + } + + /// n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion, overflow, and n == 0 + RAMUniquePtr make_unique_array_for_overwrite(size_t n) { + static_assert(std::is_trivially_default_constructible_v, "elements are left unconstructed"); + static_assert(alignof(T) <= alignof(std::max_align_t), "malloc storage cannot hold an over aligned type"); + if (n == 0 || n > SIZE_MAX / sizeof(T)) + return {}; + return RAMUniquePtr(this->allocate(n)); + } + /** * Return the total heap space available via this allocator */ @@ -2255,6 +2282,19 @@ template class RAMAllocator { template using ExternalRAMAllocator = RAMAllocator; +/// Destroys and frees RAMAllocator storage. Not convertible: free() needs the address malloc returned +template struct RAMDeleter { + void operator()(T *p) const { + p->~T(); + RAMAllocator().deallocate(p, 1); + } +}; +/// Array form: elements must be trivial, the count is not stored so only the storage is freed +template struct RAMDeleter { + static_assert(std::is_trivially_destructible_v, "RAMUniquePtr is for trivially destructible elements"); + void operator()(T *p) const { RAMAllocator().deallocate(p, 1); } +}; + /** * Functions to constrain the range of arithmetic values. */ diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index d6b31508d17..72af605d61f 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -367,4 +367,56 @@ TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) { EXPECT_EQ(v.size(), 1u); } +// --- RAMAllocator::make_unique() --- + +namespace { +struct Probe { + static inline int live = 0; + int a; + int b; + Probe(int a, int b) : a(a), b(b) { live++; } + ~Probe() { live--; } +}; +} // namespace + +static_assert(sizeof(RAMUniquePtr) == sizeof(Probe *), "the deleter must not add storage"); + +TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) { + auto p = RAMAllocator().make_unique(3, 4); + ASSERT_NE(p, nullptr); + EXPECT_EQ(p->a, 3); + EXPECT_EQ(p->b, 4); + EXPECT_EQ(Probe::live, 1); + p.reset(); + EXPECT_EQ(Probe::live, 0); +} + +TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) { + struct Plain { + uint32_t words[8]; + }; + // Dirty a block of the same size first so a recycled allocation is not zero by chance + auto dirty = RAMAllocator().make_unique_array_for_overwrite(sizeof(Plain)); + std::memset(dirty.get(), 0xFF, sizeof(Plain)); + dirty.reset(); + auto p = RAMAllocator().make_unique(); + ASSERT_NE(p, nullptr); + // Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused + EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; })); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) { + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr); + EXPECT_EQ(RAMAllocator().make_unique_array_for_overwrite(0), nullptr); + EXPECT_NE(RAMAllocator().make_unique_array_for_overwrite(1), nullptr); +} + +TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) { + RAMUniquePtr buf = RAMAllocator().make_unique_array_for_overwrite(256); + ASSERT_NE(buf, nullptr); + std::memset(buf.get(), 0xA5, 256); + EXPECT_EQ(buf[0], 0xA5); + EXPECT_EQ(buf[255], 0xA5); +} + } // namespace esphome::core::testing From 64fd87f43670a0112a8b1b21eed346f8d315fd72 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sun, 13 Sep 2026 18:01:58 -0400 Subject: [PATCH 129/759] [i2s_audio][router] Loop thread controls all state changes (#19089) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 ++++++++----- .../router/speaker/router_speaker.cpp | 21 ++++++++++++++++--- .../router/speaker/router_speaker.h | 3 +++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 1382a870465..9feaf39ffff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -239,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -249,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/router/speaker/router_speaker.cpp b/esphome/components/router/speaker/router_speaker.cpp index f4bf7420ab0..dd2428e4df4 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esp_timer.h" @@ -12,6 +14,9 @@ namespace esphome::router { static const char *const TAG = "router.speaker"; +// Maximum time to wait for the active output to report running after start() before giving up +static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; + static inline uint32_t atomic_subtract_clamped(std::atomic &var, uint32_t amount) { uint32_t current = var.load(std::memory_order_acquire); uint32_t subtracted = 0; @@ -72,6 +77,7 @@ void Router::loop() { this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + this->state_start_ms_ = App.get_loop_component_start_time(); active->start(); } return; @@ -86,10 +92,17 @@ void Router::loop() { // set_audio_stream_info() and never reaches the output on its own; if the format // changed while stopped, only start()'s apply_cached_state_to_active_() pushes it // down before the output's play()-side auto-start locks in the stale format. - if (active->is_stopped()) { + // While STARTING, ignore a transient stopped report as speaker running state + // is set asynchronously from start(). Timeout if the speaker never transitions. + if (this->state_ == speaker::STATE_STARTING) { + if (active->is_running()) { + this->state_ = speaker::STATE_RUNNING; + } else if ((App.get_loop_component_start_time() - this->state_start_ms_) > STATE_TRANSITION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Active output did not start; giving up"); + this->state_ = speaker::STATE_STOPPED; + } + } else if (active->is_stopped()) { this->state_ = speaker::STATE_STOPPED; - } else if (this->state_ == speaker::STATE_STARTING && active->is_running()) { - this->state_ = speaker::STATE_RUNNING; } } @@ -133,6 +146,8 @@ void Router::start() { this->frames_in_pipeline_.store(0, std::memory_order_release); this->apply_cached_state_to_active_(); this->state_ = speaker::STATE_STARTING; + // May run on a producer task, so the cached loop timestamp is not usable here + this->state_start_ms_ = millis(); this->get_active_output()->start(); } diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 801d0906cee..31f3f906301 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -59,6 +59,9 @@ class Router final : public Component, public speaker::Speaker { // frames_in_pipeline_. std::atomic frames_in_pipeline_{0}; + // Set when entering STATE_STARTING; used to time out a start the output never acts on + uint32_t state_start_ms_{0}; + bool cached_pause_{false}; void apply_cached_state_to_active_(); From be2c3dda94ea17d596e80b3332f920d783921503 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:02:42 -0500 Subject: [PATCH 130/759] [esp32_ble_tracker] Revert coexistence preference to balanced when OTA starts (#19082) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index b4b793b4d0b..e25b6f59fa0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -62,6 +62,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; From b0b75f705a6cda23e12ed33fac7c6f1e307e3eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:14 -0500 Subject: [PATCH 131/759] [nextion] Allocate queue components through RAMAllocator and free entries the way they were allocated (#19246) --- esphome/components/nextion/nextion.cpp | 152 +++++++++--------- esphome/components/nextion/nextion.h | 2 + .../nextion/nextion_component_base.h | 5 +- 3 files changed, 78 insertions(+), 81 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97910ba3d55..625c915e732 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -13,6 +13,11 @@ namespace esphome::nextion { static const char *const TAG = "nextion"; +// A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag +static bool is_sleep_wake_command(const NextionComponentBase *component) { + return component->get_queue_type() == NextionQueueType::NO_RESULT && component->get_variable_name() == "sleep_wake"; +} + // Nextion command terminator: three consecutive 0xFF bytes (per Nextion Instruction Set v1.1). static constexpr uint8_t COMMAND_DELIMITER[3] = {0xFF, 0xFF, 0xFF}; static constexpr size_t DELIMITER_SIZE = sizeof(COMMAND_DELIMITER); @@ -163,6 +168,17 @@ bool Nextion::check_connect_() { #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE } +// NO_RESULT components are owned by their entry; every other component is a user entity. Entry and +// component storage comes from RAMAllocator, so delete is not valid for either. +void Nextion::release_queue_entry_(NextionQueue *nb) { + if (nb->component != nullptr && nb->component->get_queue_type() == NextionQueueType::NO_RESULT) { + nb->component->~NextionComponentBase(); + RAMAllocator().deallocate(nb->component, 1); + } + nb->~NextionQueue(); + RAMAllocator().deallocate(nb, 1); +} + void Nextion::reset_(bool reset_nextion) { uint8_t d; @@ -170,15 +186,12 @@ void Nextion::reset_(bool reset_nextion) { this->read_byte(&d); } for (auto *entry : this->nextion_queue_) { - if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { - delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) - } - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->nextion_queue_.clear(); #ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { - delete entry; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(entry); } this->waveform_queue_.clear(); #endif // USE_NEXTION_WAVEFORM @@ -421,6 +434,9 @@ bool Nextion::remove_from_q_(bool report_empty) { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return false; } @@ -428,13 +444,10 @@ bool Nextion::remove_from_q_(bool report_empty) { ESP_LOGN(TAG, "Removed: %s", component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); return true; } @@ -544,7 +557,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); } #else // USE_NEXTION_WAVEFORM @@ -647,6 +660,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue entry"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -660,7 +676,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_string(to_process, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -687,6 +703,9 @@ void Nextion::process_nextion_commands_() { NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { ESP_LOGE(TAG, "Invalid queue"); + if (nb != nullptr) { + this->release_queue_entry_(nb); + } this->nextion_queue_.pop_front(); return; } @@ -703,7 +722,7 @@ void Nextion::process_nextion_commands_() { component->set_state_from_int(value, true, false); } - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->nextion_queue_.pop_front(); break; @@ -890,7 +909,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); component->clear_wave_buffer(buffer_to_send); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nb); this->waveform_queue_.pop(); #else // USE_NEXTION_WAVEFORM ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); @@ -920,14 +939,10 @@ void Nextion::purge_stale_queue_entries_() { ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), component->get_variable_name().c_str()); - if (component->get_queue_type() == NextionQueueType::NO_RESULT) { - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - delete component; // NOLINT(cppcoreguidelines-owning-memory) + if (is_sleep_wake_command(component)) { + this->is_sleeping_ = false; } - - delete *it; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(*it); it = this->nextion_queue_.erase(it); } else { @@ -1079,6 +1094,34 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool return response.length(); } +// Allocates a queue entry owning a bare NO_RESULT component; nullptr when the queue is full or memory is out +NextionQueue *Nextion::make_no_result_entry_(const std::string &variable_name) { +#ifdef USE_NEXTION_MAX_QUEUE_SIZE + if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { + ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + return nullptr; + } +#endif + + auto *nextion_queue = RAMAllocator().allocate(1); + if (nextion_queue == nullptr) { + ESP_LOGW(TAG, "Queue alloc failed"); + return nullptr; + } + new (nextion_queue) nextion::NextionQueue; + + nextion_queue->component = RAMAllocator().allocate(1); + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + this->release_queue_entry_(nextion_queue); + return nullptr; + } + new (nextion_queue->component) nextion::NextionComponentBase; + nextion_queue->component->set_variable_name(variable_name); + nextion_queue->queue_time = App.get_loop_component_start_time(); + return nextion_queue; +} + /** * @brief Add a command to the Nextion queue that expects no response. * @@ -1090,36 +1133,11 @@ uint16_t Nextion::recv_ret_string_(std::string &response, uint32_t timeout, bool * @param variable_name Name of the variable or component associated with the command. */ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - - nextion_queue->queue_time = App.get_loop_component_start_time(); - this->nextion_queue_.push_back(nextion_queue); - - ESP_LOGN(TAG, "Queue NORESULT: %s", nextion_queue->component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue NORESULT: %s", variable_name.c_str()); } /** @@ -1153,32 +1171,10 @@ void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_n #ifdef USE_NEXTION_COMMAND_SPACING void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &variable_name, const std::string &command) { -#ifdef USE_NEXTION_MAX_QUEUE_SIZE - if (this->max_queue_size_ > 0 && this->nextion_queue_.size() >= this->max_queue_size_) { - ESP_LOGW(TAG, "Queue full (%zu), drop: %s", this->nextion_queue_.size(), variable_name.c_str()); + auto *nextion_queue = this->make_no_result_entry_(variable_name); + if (nextion_queue == nullptr) return; - } -#endif - - RAMAllocator allocator; - nextion::NextionQueue *nextion_queue = allocator.allocate(1); - if (nextion_queue == nullptr) { - ESP_LOGW(TAG, "Queue alloc failed"); - return; - } - new (nextion_queue) nextion::NextionQueue(); - - nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; - if (nextion_queue->component == nullptr) { - ESP_LOGW(TAG, "Component alloc failed"); - nextion_queue->~NextionQueue(); - allocator.deallocate(nextion_queue, 1); - return; - } - nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry - this->nextion_queue_.push_back(nextion_queue); ESP_LOGVV(TAG, "Queue with pending command: %s", variable_name.c_str()); } @@ -1312,7 +1308,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1334,7 +1330,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { if (this->send_command_(command)) { this->nextion_queue_.push_back(nextion_queue); } else { - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); } #endif // USE_NEXTION_COMMAND_SPACING } @@ -1355,14 +1351,14 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { ESP_LOGW(TAG, "Queue alloc failed"); return; } - new (nextion_queue) nextion::NextionQueue(); + new (nextion_queue) nextion::NextionQueue; nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); if (!this->waveform_queue_.push(nextion_queue)) { ESP_LOGW(TAG, "Waveform queue full, drop"); - delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + this->release_queue_entry_(nextion_queue); return; } if (this->waveform_queue_.size() == 1) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index aa9fe8abb3f..6c9c8760f8a 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1469,6 +1469,8 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; bool remove_from_q_(bool report_empty = true); + void release_queue_entry_(NextionQueue *nb); + NextionQueue *make_no_result_entry_(const std::string &variable_name); /** * @brief Status flags for Nextion display state management diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 6676d019201..5e84291b168 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -23,8 +23,7 @@ class NextionComponentBase; class NextionQueue { public: - virtual ~NextionQueue() = default; - NextionComponentBase *component; + NextionComponentBase *component{nullptr}; uint32_t queue_time = 0; // Store command for retry if spacing blocked it @@ -105,6 +104,6 @@ class NextionComponentBase { int wave_max_length_ = 255; #endif // USE_NEXTION_WAVEFORM - bool needs_to_send_update_; + bool needs_to_send_update_{false}; }; } // namespace esphome::nextion From 977542061d285694ab94967757d70f0a698a2269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:26:28 -0500 Subject: [PATCH 132/759] [esphome] Allocate the OTA noise session and auth buffer through RAMAllocator (#19249) --- esphome/components/esphome/ota/ota_esphome.cpp | 9 ++++++++- esphome/components/esphome/ota/ota_esphome.h | 4 ++-- esphome/components/esphome/ota/ota_esphome_noise.cpp | 6 ++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f853ed6a2db..3010df10561 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -842,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index c6f710b3fcb..68dd0ffb9ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -145,13 +145,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION #ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; #endif - std::unique_ptr noise_; + RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d0..65476572a1e 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,7 +7,6 @@ #include "esphome/core/log.h" #include -#include #ifdef USE_ESP8266 #include @@ -43,9 +42,8 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { // A provisioned key cleared between the offer and here is not guarded: the // session runs on the zero key load_psk fills in and fails the client's MAC. - // Default-init: the frame buffer is written before it is read - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) - this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession); + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags From ebe72c3cefaaad5e664a0f9f400d88da5b11262d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:27:03 -0500 Subject: [PATCH 133/759] [core] Resolve file paths against the YAML file that declares them (#19259) --- esphome/config_validation.py | 66 +++++++----- tests/unit_tests/test_config_validation.py | 120 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 685a9d04b3f..2346c28ccef 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -15,6 +15,7 @@ from ipaddress import ( ip_network, ) import logging +import os from pathlib import Path import re from string import ascii_letters, digits @@ -1967,38 +1968,51 @@ def _remap_bundle_path(value: str) -> Path | None: return remap_bundle_path(value) -def directory(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) +def _declaring_document(value: str) -> Path | None: + """Return the on-disk YAML file *value* was loaded from, absolute, or None.""" + esp_range = getattr(value, "esp_range", None) + if esp_range is None: + return None + document = Path(esp_range.start_mark.document).absolute() + return document if document.is_file() else None - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: + +def _existing_path(value: str, kind: str, is_kind: Callable[[Path], bool]) -> Path: + """Resolve *value* to a *kind* entry: config dir, then declaring document, then bundle remap.""" + path = CORE.relative_config_path(value) + if is_kind(path): + return path + candidates = [path] + tried_document: Path | None = None + if (document := _declaring_document(value)) is not None: + beside_document = document.parent / Path(value).expanduser() + if os.path.normpath(beside_document) != os.path.normpath(path): + candidates.append(beside_document) + tried_document = document + if (remapped := _remap_bundle_path(value)) is not None: + candidates.append(remapped) + for candidate in candidates: + if is_kind(candidate): + return candidate + for candidate in candidates: + if candidate.exists(): raise Invalid( - f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})." + f"Path '{candidate}' is not a {kind} (full path: {candidate.resolve()})." ) - path = remapped - if not path.is_dir(): - raise Invalid( - f"Path '{path}' is not a directory (full path: {path.resolve()})." - ) - return path + also = ( + f" Also looked next to {tried_document}." if tried_document is not None else "" + ) + raise Invalid( + f"Could not find {kind} '{path}'. Please make sure it exists (full path: {path.resolve()}).{also}" + ) + + +def directory(value: object) -> Path: + return _existing_path(string(value), "directory", Path.is_dir) def file_(value: object) -> Path: - value = string(value) - path = CORE.relative_config_path(value) - - if not path.exists(): - remapped = _remap_bundle_path(value) - if remapped is None: - raise Invalid( - f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})." - ) - path = remapped - if not path.is_file(): - raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).") - return path + return _existing_path(string(value), "file", Path.is_file) ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 457b9d017b8..52070e7abac 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,4 +1,5 @@ import importlib +import io import json import logging from pathlib import Path @@ -20,6 +21,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.substitutions import do_substitution_pass from esphome.config_validation import Invalid from esphome.const import ( CONF_DAY, @@ -65,7 +67,13 @@ from esphome.core import ( ) from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry -from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + SensitiveStr, + load_yaml, + make_data_base, + parse_yaml, +) def test_check_not_templatable__invalid(): @@ -3145,6 +3153,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None: assert cv.file_("partitions.csv") == setup_core / "partitions.csv" +def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]: + """Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value.""" + package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor" + (package_dir / "assets").mkdir(parents=True) + (package_dir / "assets" / "ui.js").write_text("js\n") + (package_dir / "device.yaml").write_text(f"path: {path}\n") + return package_dir, load_yaml(package_dir / "device.yaml")["path"] + + +def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None: + """A package's own asset path resolves against the package file when the config dir lacks it.""" + package_dir, value = _package_value(setup_core) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__resolves_a_substituted_path_against_the_use_site( + setup_core: Path, +) -> None: + package_dir, _ = _package_value(setup_core) + (package_dir / "device.yaml").write_text( + "substitutions:\n ui: assets/ui.js\npath: ${ui}\n" + ) + config = do_substitution_pass(load_yaml(package_dir / "device.yaml")) + + assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js" + + +def test_file__result_is_absolute_for_a_relative_document( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A document loaded by a cwd-relative path still yields an absolute result.""" + package_dir, _ = _package_value(setup_core) + monkeypatch.chdir(setup_core) + value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"] + + result = cv.file_(value) + + assert result.is_absolute() + assert result == package_dir / "assets" / "ui.js" + + +def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core) + (setup_core / "assets" / "ui.js").mkdir(parents=True) + + assert cv.file_(value) == package_dir / "assets" / "ui.js" + + +def test_file__miss_names_the_declaring_document(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets/other.js") + + with pytest.raises(Invalid, match="Could not find file") as excinfo: + cv.file_(value) + + assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value) + + +def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint( + setup_core: Path, +) -> None: + (setup_core / "sub").mkdir() + (setup_core / "device.yaml").write_text("path: assets/other.js\n") + value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"] + + with pytest.raises(Invalid) as excinfo: + cv.file_(value) + + assert "Also looked" not in str(excinfo.value) + + +def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None: + package_dir, value = _package_value(setup_core, "assets") + + with pytest.raises(Invalid, match="is not a file") as excinfo: + cv.file_(value) + + assert str(package_dir / "assets") in str(excinfo.value) + + +def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None: + _, value = _package_value(setup_core) + (setup_core / "assets").mkdir() + (setup_core / "assets" / "ui.js").write_text("local\n") + + assert cv.file_(value) == setup_core / "assets" / "ui.js" + + +def test_file__declared_in_an_in_memory_document_is_not_resolved( + setup_core: Path, +) -> None: + """A value whose source document isn't on disk falls through to the config-dir error.""" + value = parse_yaml(Path(""), io.StringIO("path: assets/ui.js\n"))[ + "path" + ] + + with pytest.raises(Invalid, match="Could not find file"): + cv.file_(value) + + +def test_directory_resolves_relative_to_the_declaring_document( + setup_core: Path, +) -> None: + package_dir, value = _package_value(setup_core, "assets") + + assert cv.directory(value) == package_dir / "assets" + + def test_file__missing_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="Could not find file"): cv.file_("partitions.csv") From e28b4eb2a0584792102702af9f1a47fc2b5d751f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:35:38 -0500 Subject: [PATCH 134/759] [ethernet] Keep the W5500 SPI context in a static instance instead of the heap (#19248) --- .../components/ethernet/w5500_custom_spi.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger // transfers (the frame payloads) use the blocking, DMA-backed transmit. @@ -25,23 +29,20 @@ constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; void *w5500_custom_spi_init(const void *spi_config) { const auto *config = static_cast(spi_config); - auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; - if (ctx == nullptr) { - return nullptr; - } + auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. spi_device_interface_config_t devcfg = *config->spi_devcfg; devcfg.command_bits = 16; devcfg.address_bits = 8; if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { - delete ctx; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { auto *ctx = static_cast(spi_ctx); spi_bus_remove_device(ctx->handle); vSemaphoreDelete(ctx->lock); - delete ctx; + *ctx = {}; return ESP_OK; } From 81a54ea9dbbd7c5482057f9993ce8bda1a4f7047 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 17:37:04 -0500 Subject: [PATCH 135/759] [ota] Allocate the signature block through RAMAllocator (#19251) --- esphome/components/ota/ota_signature_esp_idf.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 501d6ac241d..2192a794410 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -235,9 +234,11 @@ bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) { // runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer // and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens // a thin margin. One short-lived allocation right before reboot is not the - // fragmentation pattern the project guards against. nothrow so an OOM here - // fails closed like every other error path, rather than aborting. - std::unique_ptr block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]); + // fragmentation pattern the project guards against. An OOM returns nullptr + // and fails closed like every other error path. Internal RAM first: the + // block is an esp_partition_read target. + auto block = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(SIG_BLOCK_SIZE); if (!block) { OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory"); return false; From 501009073d1f6da6245c4100fc2de1a44a202922 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:55:13 +1200 Subject: [PATCH 136/759] [core] Clear loaded_platforms on CORE.reset() (#19268) --- esphome/core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 6e3f91af22f..5fcad90a81a 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -715,6 +715,7 @@ class EsphomeCore: self.defines = set() self.platformio_options = {} self.loaded_integrations = set() + self.loaded_platforms = set() self.component_ids = set() self.platform_counts = defaultdict(int) self.unique_ids = {} From 93fa95c8335585fc5d6492ca01039b252ae3820e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 20:03:00 -0500 Subject: [PATCH 137/759] [api] Reuse overflow buffer storage instead of allocating per stalled write (#19093) --- esphome/components/api/__init__.py | 5 +- esphome/components/api/api_buffer.cpp | 35 +- esphome/components/api/api_buffer.h | 24 +- esphome/components/api/api_connection.cpp | 5 +- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_frame_helper.h | 3 + .../components/api/api_frame_helper_noise.cpp | 20 +- .../components/api/api_overflow_buffer.cpp | 121 ++--- esphome/components/api/api_overflow_buffer.h | 93 ++-- tests/components/api/__init__.py | 17 + tests/components/api/test_api_buffer.cpp | 65 +++ tests/components/api/test_overflow_buffer.cpp | 510 ++++++++++++++++++ 12 files changed, 755 insertions(+), 145 deletions(-) create mode 100644 tests/components/api/__init__.py create mode 100644 tests/components/api/test_api_buffer.cpp create mode 100644 tests/components/api/test_overflow_buffer.cpp diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6202e127bfc..272b0786905 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -350,10 +350,9 @@ CONFIG_SCHEMA = cv.All( ln882x=5, # Moderate RAM nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), - # Maximum queued send buffers per connection before dropping connection - # Each buffer uses ~8-12 bytes overhead plus actual message size + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index fc45a4e971f..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,20 +1,37 @@ #include "api_buffer.h" -#include +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + bool APIBuffer::grow_(size_t n) { - // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead - // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). - // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. - std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); - if (new_data == nullptr) + if (n > MAX_SIZE) return false; - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; return true; } +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; +} + } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 396dadbe587..7caa68aa4d5 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -25,6 +25,7 @@ namespace esphome::api { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths void clear() { this->size_ = 0; } /// Returns false if allocation fails; the buffer is left unchanged. [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } @@ -36,9 +37,19 @@ class APIBuffer { [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { if (!this->reserve(std::max(reserve_size, new_size))) return false; - this->size_ = new_size; + this->size_ = static_cast(new_size); return true; } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } @@ -55,9 +66,14 @@ class APIBuffer { protected: bool grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index da4b7d7702f..cc0543a690b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -364,7 +364,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 38da444a189..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,7 +171,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // Queue unsent data into overflow buffer - if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c0..a68a0ad0d87 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee8..400cd1d9b86 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -67,15 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -272,17 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index 48d8fe18ba8..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,98 +1,91 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include -#include namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { - // socket->write() can re-enter this function: a log message emitted from an - // lwip callback during the write goes out over the API and lands back in the - // frame helper's write/drain path. If a nested drain ran here it would send - // and free the entry the outer drain is still holding, causing a double free. - // Report "no progress" instead; the outer drain keeps draining, and the - // nested send is enqueued behind the existing backlog. + // Nested call from inside socket->write(); see draining_ if (this->draining_) return 0; - // RAII so the flag is cleared on every return path struct DrainGuard { - explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } - ~DrainGuard() { this->flag_ = false; } - bool &flag_; - } guard(this->draining_); + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — unlink it before freeing so a freed pointer is never - // reachable from the queue - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; - Entry::destroy(front); } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // nothrow: a failed allocation returns nullptr so the connection is dropped - // cleanly instead of plain new's crash or abort on OOM - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *data = new (std::nothrow) uint8_t[buffer_size]; - if (data == nullptr) - return false; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; - if (entry == nullptr) { - delete[] data; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) return false; + + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; } - uint16_t to_skip = skip; - uint16_t write_pos = 0; - - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - // Publish only after the copy completes so a half-built entry is never reachable - this->queue_[this->tail_] = entry; - this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; } diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 03a334b281a..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,71 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// Enqueue unsent IOV data into the backlog. - /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full or allocation fails (caller should fail the connection). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; - // Guards against re-entrant drains: socket->write() can re-enter the API - // send path (e.g. a log message emitted from an lwip callback), and a nested - // drain would free the entry the outer drain is still holding. - bool draining_{false}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py new file mode 100644 index 00000000000..2aa558726c3 --- /dev/null +++ b/tests/components/api/__init__.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.core import CORE +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # USE_API compiles every api source, so emit what they need. No socket + # override: an __init__.py there makes pytest import its conftest as socket.conftest. + async def to_code_testing(config): + cg.add_define("USE_API") + cg.add_define("USE_API_PLAINTEXT") + cg.add_define("API_MAX_SEND_QUEUE", 8) + cg.add_define("MAX_API_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + CORE.register_controller() # api_server registers with the controller registry + + manifest.to_code = to_code_testing diff --git a/tests/components/api/test_api_buffer.cpp b/tests/components/api/test_api_buffer.cpp new file mode 100644 index 00000000000..c54780050e3 --- /dev/null +++ b/tests/components/api/test_api_buffer.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::testing { + +// Pointer plus two 16 bit sizes +static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *)); + +TEST(APIBuffer, RefusesSizesAbove16Bits) { + APIBuffer buf; + ASSERT_TRUE(buf.resize(16)); + EXPECT_FALSE(buf.reserve(UINT16_MAX + 1)); + EXPECT_EQ(buf.size(), 16u); + EXPECT_EQ(buf.capacity(), 16u); + EXPECT_TRUE(buf.reserve(UINT16_MAX)); + EXPECT_EQ(buf.capacity(), UINT16_MAX); +} + +static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6}; + +TEST(APIBuffer, AppendReturnsTheNewBytes) { + APIBuffer buf; + ASSERT_TRUE(buf.reserve(8)); + uint8_t *first = buf.append(3); + ASSERT_NE(first, nullptr); + std::memcpy(first, BYTES, 3); + EXPECT_EQ(buf.size(), 3u); + EXPECT_EQ(buf.capacity(), 8u); + + // Grows through realloc and keeps what was there + uint8_t *second = buf.append(6); + ASSERT_EQ(second, buf.data() + 3); + std::memcpy(second, BYTES + 3, 3); + EXPECT_EQ(buf.size(), 9u); + EXPECT_EQ(buf.capacity(), 9u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0); +} + +TEST(APIBuffer, DropFrontSlidesTheRestDown) { + APIBuffer buf; + uint8_t *bytes = buf.append(6); + ASSERT_NE(bytes, nullptr); + std::memcpy(bytes, BYTES, 6); + + buf.drop_front(2); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(buf.capacity(), 6u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Growing afterwards keeps the slid bytes + ASSERT_TRUE(buf.reserve(64)); + EXPECT_EQ(buf.size(), 4u); + EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0); + + // Dropping everything leaves an empty buffer with its capacity + buf.drop_front(4); + EXPECT_EQ(buf.size(), 0u); + EXPECT_EQ(buf.capacity(), 64u); +} + +} // namespace esphome::api::testing diff --git a/tests/components/api/test_overflow_buffer.cpp b/tests/components/api/test_overflow_buffer.cpp new file mode 100644 index 00000000000..4b27e544963 --- /dev/null +++ b/tests/components/api/test_overflow_buffer.cpp @@ -0,0 +1,510 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_overflow_buffer.h" + +#ifdef USE_HOST +namespace esphome::api::testing { + +// Idle cost is the buffer plus one word of bookkeeping +static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *)); + +// Exposes storage so tests can check it is reused, not reallocated +class TestOverflowBuffer : public APIOverflowBuffer { + public: + using APIOverflowBuffer::LEN_PREFIX; + using APIOverflowBuffer::MAX_BYTES; + using APIOverflowBuffer::MAX_LONE_BYTES; + struct Storage { + size_t capacity; + const uint8_t *data; + bool operator==(const Storage &) const = default; + }; + size_t capacity() const { return this->buf_.capacity(); } + Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; } + uint8_t count() const { return this->count_; } + size_t live() const { return this->buf_.size() - this->head_; } + /// Simulates a socket write inside try_drain() re-entering the send path + void set_draining(bool draining) { this->draining_ = draining; } +}; + +static std::vector make_message(size_t len, uint8_t seed) { + std::vector msg(len); + for (size_t i = 0; i < len; i++) + msg[i] = static_cast(seed + i); + return msg; +} + +static bool enqueue(TestOverflowBuffer &buf, const std::vector &msg, uint16_t skip = 0) { + struct iovec iov = {const_cast(msg.data()), msg.size()}; + return buf.enqueue_iov(&iov, 1, static_cast(msg.size()), skip); +} + +static void append(std::vector &dst, const std::vector &src, size_t skip = 0) { + dst.insert(dst.end(), src.begin() + skip, src.end()); +} + +static std::vector concat(std::initializer_list> parts) { + std::vector out; + for (const auto &part : parts) + append(out, part); + return out; +} + +/// The pipe delivers the filler first, then the drained messages. +static void expect_after_filler(const std::vector &received, size_t filler, + const std::vector &expected) { + ASSERT_EQ(received.size(), filler + expected.size()); + EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler)); +} + +// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection +class OverflowBufferTest : public ::testing::Test { + protected: + void SetUp() override { + int fds[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + int size = 4096; + ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0); + ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0); + this->reader_ = fds[1]; + this->sock_ = std::make_unique(fds[0]); + ASSERT_EQ(this->sock_->setblocking(false), 0); + } + void TearDown() override { ::close(this->reader_); } + + /// Write filler until the socket refuses; returns the bytes accepted + size_t fill_pipe_() { + uint8_t junk[512]; + std::memset(junk, 0xEE, sizeof(junk)); + size_t total = 0; + for (;;) { + ssize_t written = this->sock_->write(junk, sizeof(junk)); + if (written <= 0) + break; + total += static_cast(written); + } + return total; + } + + /// Append whatever the pipe currently holds. + void read_into_(std::vector &out) { + uint8_t tmp[1024]; + for (;;) { + ssize_t n = ::read(this->reader_, tmp, sizeof(tmp)); + if (n <= 0) + break; + out.insert(out.end(), tmp, tmp + n); + } + } + + /// Drain once; a refusal must be a would-block, never a hard error. + ssize_t drain_(TestOverflowBuffer &buf) { + ssize_t sent = buf.try_drain(this->sock_.get()); + if (sent == -1) { + EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN); + } + return sent; + } + + /// Read and drain until the backlog is empty; returns all bytes received + std::vector drain_all_(TestOverflowBuffer &buf) { + std::vector received; + for (int i = 0; i < 10000 && !buf.empty(); i++) { + this->read_into_(received); + // A hard socket error would never clear the backlog; stop instead of spinning + if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN) + break; + } + EXPECT_TRUE(buf.empty()); + this->read_into_(received); + return received; + } + + struct Stall { + size_t filler; + std::vector first, second, received; + TestOverflowBuffer::Storage before; + }; + /// Park two messages, then drain the first fully and the second part way + void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) { + s.filler = this->fill_pipe_(); + s.first = make_message(1500, 20); + ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go + // Larger than the whole pipe, so a drain always stops inside it + s.second = make_message(std::max(s.filler + 1, std::min(s.filler * 3, 12000)), 60); + ASSERT_GT(s.second.size(), s.filler); + ASSERT_TRUE(enqueue(buf, s.first)); + ASSERT_TRUE(enqueue(buf, s.second)); + s.before = buf.storage(); + this->read_into_(s.received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + } + + int reader_{-1}; + std::unique_ptr sock_; +}; + +TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) { + TestOverflowBuffer buf; + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 1); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const auto storage = buf.storage(); + EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX); + + for (int stall = 0; stall < 5; stall++) { + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + // Same allocation every time: no free, no new allocation + EXPECT_EQ(buf.storage(), storage); + + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.storage(), storage); + } +} + +TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) { + TestOverflowBuffer buf; + auto msg = make_message(1000, 7); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + const size_t capacity = buf.capacity(); + + // Requested while the backlog still holds data: storage must stay until sent + buf.release(); + EXPECT_FALSE(buf.empty()); + EXPECT_EQ(buf.capacity(), capacity); + + expect_after_filler(this->drain_all_(buf), filler, msg); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); + + // A later stall allocates again and keeps it, since nobody asked for a release + filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_GT(buf.capacity(), 0u); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) { + TestOverflowBuffer buf; + auto msg = make_message(100, 3); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + this->drain_all_(buf); + EXPECT_GT(buf.capacity(), 0u); + + buf.release(); + EXPECT_EQ(buf.capacity(), 0u); + EXPECT_EQ(buf.storage().data, nullptr); +} + +TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) { + TestOverflowBuffer buf; + auto first = make_message(700, 10); + auto second_a = make_message(300, 50); + auto second_b = make_message(400, 90); + auto third = make_message(200, 130); + + size_t filler = this->fill_pipe_(); + // 100 bytes of the first message were already accepted by the socket + ASSERT_TRUE(enqueue(buf, first, 100)); + // Two iovecs with the skip covering all of the first one plus part of the second + struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}}; + const uint16_t second_skip = static_cast(second_a.size() + 5); + ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast(second_a.size() + second_b.size()), second_skip)); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 3); + + // Nothing can go out while the pipe is full + EXPECT_EQ(this->drain_(buf), -1); + EXPECT_EQ(buf.count(), 3); + + std::vector expected; + append(expected, first, 100); + append(expected, second_b, 5); + append(expected, third); + expect_after_filler(this->drain_all_(buf), filler, expected); +} + +TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) { + TestOverflowBuffer buf; + auto msg = make_message(16, 1); + + size_t filler = this->fill_pipe_(); + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) { + ASSERT_TRUE(enqueue(buf, msg)) << "message " << i; + } + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE); + + // Draining frees the slots again + std::vector expected; + for (int i = 0; i < API_MAX_SEND_QUEUE; i++) + append(expected, msg); + expect_after_filler(this->drain_all_(buf), filler, expected); + this->fill_pipe_(); + EXPECT_TRUE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 1); +} + +TEST_F(OverflowBufferTest, SkipAtIovecBoundary) { + TestOverflowBuffer buf; + auto sent = make_message(300, 50); + auto unsent = make_message(400, 90); + + size_t filler = this->fill_pipe_(); + // The skip covers the first iovec exactly, so only the second is copied + struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}}; + ASSERT_TRUE( + buf.enqueue_iov(iov, 2, static_cast(sent.size() + unsent.size()), static_cast(sent.size()))); + EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX); + expect_after_filler(this->drain_all_(buf), filler, unsent); +} + +TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) { + TestOverflowBuffer buf; + size_t filler = this->fill_pipe_(); + auto first = make_message(200, 20); + // Size the second message so the two land half way into a 256 byte step, + // leaving exactly 128 bytes of slack whatever the pipe accepted + const size_t base = std::max(filler + 1, std::min(filler * 3, 12000)); + const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + auto second = make_message(second_len, 60); + ASSERT_GT(second.size(), filler); + ASSERT_TRUE(enqueue(buf, first)); + ASSERT_TRUE(enqueue(buf, second)); + const auto storage = buf.storage(); + const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX; + ASSERT_EQ(slack, 128u); + auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200); + + std::vector received; + this->read_into_(received); + ASSERT_GT(this->drain_(buf), 0); + ASSERT_EQ(buf.count(), 1); + const size_t live = buf.live(); + + // Fits in the tail, so the sent prefix is left alone + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), storage); + EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX); + + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, concat({first, second, third})); +} + +TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) { + TestOverflowBuffer buf; + auto first = make_message(300, 7); + auto second = make_message(300, 70); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + buf.release(); + ASSERT_TRUE(enqueue(buf, second)); + EXPECT_GT(buf.capacity(), 0u); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) { + TestOverflowBuffer buf; + // Two of these fill the byte budget exactly, well before the slot count is reached + static_assert(API_MAX_SEND_QUEUE >= 3); + auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + ASSERT_TRUE(enqueue(buf, msg)); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_EQ(buf.count(), 2); +} + +TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) { + TestOverflowBuffer buf; + // The oversized message must still fit under the lone message ceiling + static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <= + TestOverflowBuffer::MAX_LONE_BYTES); + auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5); + auto small = make_message(16, 9); + + // Refusing the only message would drop the connection for nothing + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, big)); + EXPECT_EQ(buf.count(), 1); + // With a backlog present the byte limit applies again + EXPECT_FALSE(enqueue(buf, small)); + EXPECT_EQ(buf.count(), 1); + + expect_after_filler(this->drain_all_(buf), filler, big); +} + +TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) { + TestOverflowBuffer buf; + // Payload plus prefix is past the lone message ceiling + auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3); + + this->fill_pipe_(); + EXPECT_FALSE(enqueue(buf, msg)); + EXPECT_TRUE(buf.empty()); + EXPECT_EQ(buf.capacity(), 0u); +} + +TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + // A closed socket fails every write outright, unlike a full one + ASSERT_EQ(this->sock_->close(), 0); + + errno = 0; + EXPECT_EQ(buf.try_drain(this->sock_.get()), -1); + EXPECT_NE(errno, EWOULDBLOCK); + EXPECT_NE(errno, EAGAIN); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX); +} + +TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + + // One byte too many to fit even after the sent prefix is reclaimed: grows in one copy + auto third = make_message(s.before.capacity - buf.live() + 1, 200); + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_GT(buf.capacity(), s.before.capacity); + EXPECT_EQ(buf.count(), 2); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) { + TestOverflowBuffer buf; + auto msg = make_message(300, 40); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, msg)); + std::vector received; + this->read_into_(received); + + // Room is available, but a nested drain must leave the outer one's message alone + buf.set_draining(true); + EXPECT_EQ(this->drain_(buf), 0); + EXPECT_EQ(buf.count(), 1); + std::vector nothing; + this->read_into_(nothing); + EXPECT_TRUE(nothing.empty()); + + buf.set_draining(false); + append(received, this->drain_all_(buf)); + expect_after_filler(received, filler, msg); +} + +TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(4, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + buf.set_draining(true); + EXPECT_TRUE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 2); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, concat({first, second})); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) { + TestOverflowBuffer buf; + auto first = make_message(500, 10); + auto second = make_message(100, 90); + + size_t filler = this->fill_pipe_(); + ASSERT_TRUE(enqueue(buf, first)); + const auto storage = buf.storage(); + ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX); + + // Growing would free the bytes the outer write() is sending from + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, second)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), storage); + buf.set_draining(false); + + expect_after_filler(this->drain_all_(buf), filler, first); +} + +TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // Sliding the remainder down would move the bytes the outer write() points at + buf.set_draining(true); + EXPECT_FALSE(enqueue(buf, third)); + EXPECT_EQ(buf.count(), 1); + EXPECT_EQ(buf.storage(), s.before); + buf.set_draining(false); + + // Once the drain is over the same enqueue compacts and succeeds + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) { + TestOverflowBuffer buf; + Stall s; + ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s)); + auto third = make_message(1000, 200); + + // The sent first message is reclaimed by sliding the remainder down, not by reallocating + ASSERT_TRUE(enqueue(buf, third)); + EXPECT_EQ(buf.storage(), s.before); + + append(s.received, this->drain_all_(buf)); + expect_after_filler(s.received, s.filler, concat({s.first, s.second, third})); +} + +} // namespace esphome::api::testing +#endif // USE_HOST From 7fe0689fb8e1b0b94ce2f0e3284533ecb61d9981 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:12:27 +1200 Subject: [PATCH 138/759] Bump version to 2026.9.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 97ce92240c7..331d2f7984b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b3 +PROJECT_NUMBER = 2026.9.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index b013098f336..56961253557 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b3" +__version__ = "2026.9.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a0821c225af41045f426b41e851972e07c6ddd9f Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sun, 13 Sep 2026 18:18:52 -0700 Subject: [PATCH 139/759] [pmsa003i] Fix read from uninitialized stack memory (#19053) --- esphome/components/pmsa003i/pmsa003i.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e8793..0b5c72a94d2 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI From 41e34c19eb433ca620c80bcef842d447c1d5f491 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 13 Sep 2026 18:36:39 -0700 Subject: [PATCH 140/759] [template] Stop water heater republishing when a temperature is unknown (#19013) --- .../water_heater/template_water_heater.cpp | 10 ++++-- ...r_heater_template_unknown_temperature.yaml | 16 +++++++++ .../integration/test_water_heater_template.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/water_heater_template_unknown_temperature.yaml diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca3..9d6a3523d28 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 00000000000..a70ed25bd7f --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d69845..3d7f8851605 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) From 02648d3547401d6478149c9c9f322a5b99a467fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 141/759] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d4..45bf77b4e84 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From 82b608706cd49a231017190de0bbb8120ccc2fbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 21:17:58 -0500 Subject: [PATCH 142/759] [bluetooth_connection] Keep USE_BLUETOOTH_PROXY out of the shared host test binary (#19271) --- tests/components/bluetooth_connection/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 9c1ad4e74d4..45bf77b4e84 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; # emit the backend define so the host build exercises it. async def to_code_testing(config): - # These defines are global to the merged host test binary; safe - # because no co-compiled test observes them. + # These defines are global to the merged host test binary. The api sources are + # compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make + # them include and call bluetooth_proxy, which has no host build without a BLE hub. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") - cg.add_define("USE_BLUETOOTH_PROXY") # Gates the connection half of the API surface, which is what # close_service_batch and the GATT response types live behind. cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") - cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) manifest.to_code = to_code_testing From abadfbfd20eb16d9272ef225f160e55adad2824b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 143/759] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f7..9ef7efc96a3 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f142..3e7d345805c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc7..766cdc4afb3 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c0..3b632a1847f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b72..5c8d71696f5 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 1e57c03b7b0..95f627596d1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -288,7 +288,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -487,7 +489,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 00000000000..f7e03743198 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 282be54d1eb4c4a1ab00288229c247a06ce7975f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 144/759] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f66..fc0893323be 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9bf..3c7942b9a36 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 00000000000..b3eae34436f --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 00000000000..b33508602af --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 1e22861d11ddcd27096239b2d7d4ea130b83883c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 145/759] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f72..2459163786d 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f8..72d3bf75b1c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 00000000000..dae1c488832 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 00000000000..2d37d7ae19d --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 00000000000..3c34da43ac1 --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 00000000000..2b13ed966b5 --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From 4067f572cc953f5f98d1f2770222a4245ab21dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 146/759] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5e..10d5e5eb593 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f4235728..57c8c553f65 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 00000000000..84c5eafc5ab --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 00000000000..682ae9de511 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 00000000000..172715aef08 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a9..8fa62c0636b 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 8109aa96f628711482501d2bd5de1406eb764bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 147/759] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a3..ab9624c3649 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368c..eafa161f51e 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 00000000000..ecb33b0ea80 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 00000000000..a4fc24a7cbf --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From cba4f5bc05dfad191c27131a7b731caf7a1df53a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 148/759] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 95f627596d1..81b90766b9e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -169,6 +169,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -496,7 +499,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -606,7 +609,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -655,7 +659,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -677,10 +683,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 77a4773a279..a0983545fbd 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -919,11 +919,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 00000000000..94e5de47a0f --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 00000000000..068479a5404 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 00000000000..1b5e7d7dba9 --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 00000000000..b326f3eaeeb --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From c378ea13001066844f84b13fd8a86c016525536b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 149/759] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b030840..138db75ad10 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7ec..ae55f4145a9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7bc..4ce30afb946 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 00000000000..1f11ea4580c --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 00000000000..dc25fe99ce2 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 00000000000..ce45a6ae3fb --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From e5eb577b49ed824d3fd1a82b633f52e94c51f7cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 150/759] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..79c2e509848 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e044..be6e63b154d 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 00000000000..9ffc8af736e --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 00000000000..771e5133459 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 1e627a31f1027478206e15c3fe27d2334026e034 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:36:30 -0500 Subject: [PATCH 151/759] [ci] Refresh integration test durations (#19275) --- .../integration_test_durations.json | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index b4a7f4e1aeb..16748b4537e 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,154 +1,155 @@ { - "tests/integration/test_action_concurrent_reentry.py": 30.48, - "tests/integration/test_addressable_light_transition.py": 42.1, - "tests/integration/test_alarm_control_panel_state_transitions.py": 35.76, - "tests/integration/test_api_action_metadata.py": 22.35, - "tests/integration/test_api_action_responses.py": 30.31, - "tests/integration/test_api_action_timeout.py": 34.73, - "tests/integration/test_api_conditional_memory.py": 18.35, - "tests/integration/test_api_custom_services.py": 15.99, - "tests/integration/test_api_get_time_response_timezone.py": 24.21, - "tests/integration/test_api_homeassistant.py": 33.77, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55, - "tests/integration/test_api_list_entities_backpressure.py": 23.04, - "tests/integration/test_api_message_size_batching.py": 27.31, - "tests/integration/test_api_reboot_timeout.py": 29.32, - "tests/integration/test_api_string_lambda.py": 14.9, - "tests/integration/test_api_vv_logging.py": 26.25, - "tests/integration/test_api_zero_psk_provisioning.py": 38.19, - "tests/integration/test_areas_and_devices.py": 27.52, - "tests/integration/test_automation_wait_actions.py": 24.25, - "tests/integration/test_automations.py": 36.02, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47, - "tests/integration/test_binary_sensor_invalidate_state.py": 14.79, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52, - "tests/integration/test_build_info.py": 21.42, - "tests/integration/test_camera_mock.py": 17.02, - "tests/integration/test_climate_control_action.py": 26.56, - "tests/integration/test_climate_custom_modes.py": 18.82, - "tests/integration/test_continuation_actions.py": 20.39, - "tests/integration/test_cover_control_action.py": 19.91, - "tests/integration/test_crc8_helper.py": 16.73, - "tests/integration/test_device_id_in_state.py": 58.41, - "tests/integration/test_duplicate_entities.py": 30.76, - "tests/integration/test_entity_icon.py": 25.34, - "tests/integration/test_fan_turn_on_action.py": 23.64, - "tests/integration/test_fnv1_hash_object_id.py": 25.44, - "tests/integration/test_fnv1a_hash.py": 20.85, - "tests/integration/test_gpio_expander_cache.py": 14.42, - "tests/integration/test_host_logger_thread_safety.py": 21.31, - "tests/integration/test_host_mode_basic.py": 2.65, - "tests/integration/test_host_mode_batch_delay.py": 22.21, - "tests/integration/test_host_mode_climate_basic_state.py": 27.12, - "tests/integration/test_host_mode_climate_control.py": 21.57, - "tests/integration/test_host_mode_empty_string_options.py": 27.17, - "tests/integration/test_host_mode_entity_fields.py": 30.1, - "tests/integration/test_host_mode_fan_preset.py": 17.55, - "tests/integration/test_host_mode_many_entities.py": 38.98, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82, - "tests/integration/test_host_mode_noise_encryption.py": 39.84, - "tests/integration/test_host_mode_reconnect.py": 13.1, - "tests/integration/test_host_mode_sensor.py": 22.17, - "tests/integration/test_host_ota.py": 92.05, - "tests/integration/test_host_preferences.py": 20.29, - "tests/integration/test_host_preferences_suspend_resume.py": 15.02, - "tests/integration/test_improv_serial_uart.py": 30.15, - "tests/integration/test_large_message_batching.py": 25.84, - "tests/integration/test_legacy_area.py": 21.24, - "tests/integration/test_legacy_climate_compat.py": 17.34, - "tests/integration/test_legacy_fan_compat.py": 22.6, - "tests/integration/test_light_automations.py": 29.13, - "tests/integration/test_light_binary_effect_off_phase.py": 33.99, - "tests/integration/test_light_calls.py": 26.81, - "tests/integration/test_light_constant_brightness.py": 25.0, - "tests/integration/test_light_control_action.py": 25.57, - "tests/integration/test_light_dim_relative_action.py": 21.4, - "tests/integration/test_light_effect_zero_brightness.py": 19.65, - "tests/integration/test_light_initial_state.py": 17.58, - "tests/integration/test_light_toggle_action.py": 28.28, - "tests/integration/test_lock_automations.py": 23.3, - "tests/integration/test_logger_buffered_recursion_guard.py": 22.96, - "tests/integration/test_loop_disable_enable.py": 16.18, - "tests/integration/test_loop_interval_decoupling.py": 25.19, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59, - "tests/integration/test_lvgl_headless_render.py": 87.78, - "tests/integration/test_micros_to_millis.py": 18.73, - "tests/integration/test_multi_click_trigger.py": 24.2, - "tests/integration/test_multi_device_preferences.py": 20.52, - "tests/integration/test_noise_encryption_key_protection.py": 19.1, - "tests/integration/test_object_id_api_verification.py": 26.24, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88, - "tests/integration/test_object_id_no_friendly_name.py": 61.27, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03, - "tests/integration/test_online_image_bmp.py": 34.21, - "tests/integration/test_oversized_payloads.py": 62.75, - "tests/integration/test_preference_key_stability.py": 26.8, - "tests/integration/test_runtime_stats.py": 28.26, - "tests/integration/test_safe_mode_loop_runs.py": 18.14, - "tests/integration/test_scheduler_blocking_warning.py": 28.7, - "tests/integration/test_scheduler_bulk_cleanup.py": 20.73, - "tests/integration/test_scheduler_defer_cancel.py": 22.99, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.97, - "tests/integration/test_scheduler_defer_fifo_simple.py": 24.15, - "tests/integration/test_scheduler_defer_stress.py": 23.11, - "tests/integration/test_scheduler_heap_stress.py": 20.2, - "tests/integration/test_scheduler_internal_id_no_collision.py": 23.75, - "tests/integration/test_scheduler_interval_reschedule.py": 15.32, - "tests/integration/test_scheduler_interval_zero_coerced.py": 20.1, - "tests/integration/test_scheduler_null_name.py": 17.43, - "tests/integration/test_scheduler_numeric_id_test.py": 25.51, - "tests/integration/test_scheduler_pool.py": 24.22, - "tests/integration/test_scheduler_rapid_cancellation.py": 24.01, - "tests/integration/test_scheduler_recursive_timeout.py": 22.94, - "tests/integration/test_scheduler_removed_item_race.py": 23.07, - "tests/integration/test_scheduler_self_keyed.py": 18.43, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99, - "tests/integration/test_scheduler_string_test.py": 17.27, - "tests/integration/test_script_array_params.py": 4.59, - "tests/integration/test_script_delay_params.py": 22.46, - "tests/integration/test_script_queued.py": 25.24, - "tests/integration/test_script_queued_idle_loop.py": 5.04, - "tests/integration/test_script_wait_on_boot.py": 21.77, - "tests/integration/test_sdl_headless_screenshot.py": 19.23, - "tests/integration/test_select_stringref_trigger.py": 19.31, - "tests/integration/test_sensor_filters_delta.py": 25.92, - "tests/integration/test_sensor_filters_ring_buffer.py": 22.39, - "tests/integration/test_sensor_filters_sliding_window.py": 57.93, - "tests/integration/test_sensor_filters_value_list.py": 20.32, - "tests/integration/test_sensor_timeout_filter.py": 25.35, - "tests/integration/test_snapshot_display.py": 19.7, - "tests/integration/test_socket_wake_gate_tcp.py": 14.5, - "tests/integration/test_status_flags.py": 33.83, - "tests/integration/test_strftime_to.py": 17.64, - "tests/integration/test_syslog.py": 24.49, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81, - "tests/integration/test_template_climate_basic.py": 15.28, - "tests/integration/test_template_climate_custom_modes.py": 25.07, - "tests/integration/test_template_climate_nonoptimistic.py": 24.25, - "tests/integration/test_template_climate_on_control_ordering.py": 24.09, - "tests/integration/test_template_climate_publish_all_fields.py": 17.78, - "tests/integration/test_template_climate_sensor_push.py": 17.42, - "tests/integration/test_template_climate_set_actions.py": 23.63, - "tests/integration/test_template_climate_two_point_temperature.py": 25.19, - "tests/integration/test_template_text_save.py": 17.88, - "tests/integration/test_text_command.py": 22.71, - "tests/integration/test_text_sensor_raw_state.py": 25.17, - "tests/integration/test_uart_mock_ld2410.py": 58.15, - "tests/integration/test_uart_mock_ld2412.py": 61.14, - "tests/integration/test_uart_mock_ld2420.py": 33.87, - "tests/integration/test_uart_mock_ld2450.py": 26.06, - "tests/integration/test_uart_mock_modbus.py": 391.79, - "tests/integration/test_udp.py": 7.38, - "tests/integration/test_use_address_runtime.py": 24.09, - "tests/integration/test_valve_control_action.py": 23.22, - "tests/integration/test_varint_five_byte_device_id.py": 17.93, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.26, - "tests/integration/test_wait_until_on_boot.py": 17.46, - "tests/integration/test_wait_until_ordering.py": 11.89, - "tests/integration/test_wait_until_reentrant_restart.py": 22.88, - "tests/integration/test_wake_loop_forces_phase_b.py": 16.6, - "tests/integration/test_water_heater_template.py": 19.66 + "tests/integration/test_action_concurrent_reentry.py": 34.72, + "tests/integration/test_addressable_light_transition.py": 33.71, + "tests/integration/test_alarm_control_panel_state_transitions.py": 38.96, + "tests/integration/test_api_action_metadata.py": 33.36, + "tests/integration/test_api_action_responses.py": 26.22, + "tests/integration/test_api_action_timeout.py": 25.41, + "tests/integration/test_api_conditional_memory.py": 20.74, + "tests/integration/test_api_custom_services.py": 23.21, + "tests/integration/test_api_get_time_response_timezone.py": 25.04, + "tests/integration/test_api_homeassistant.py": 24.18, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86, + "tests/integration/test_api_list_entities_backpressure.py": 18.8, + "tests/integration/test_api_message_size_batching.py": 28.8, + "tests/integration/test_api_reboot_timeout.py": 9.47, + "tests/integration/test_api_string_lambda.py": 16.88, + "tests/integration/test_api_vv_logging.py": 17.99, + "tests/integration/test_api_zero_psk_provisioning.py": 47.07, + "tests/integration/test_areas_and_devices.py": 20.77, + "tests/integration/test_automation_wait_actions.py": 20.07, + "tests/integration/test_automations.py": 27.41, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54, + "tests/integration/test_binary_sensor_invalidate_state.py": 16.09, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7, + "tests/integration/test_build_info.py": 18.07, + "tests/integration/test_camera_mock.py": 20.44, + "tests/integration/test_climate_control_action.py": 27.97, + "tests/integration/test_climate_custom_modes.py": 26.77, + "tests/integration/test_continuation_actions.py": 12.09, + "tests/integration/test_cover_control_action.py": 19.77, + "tests/integration/test_crc8_helper.py": 12.64, + "tests/integration/test_device_id_in_state.py": 63.19, + "tests/integration/test_duplicate_entities.py": 29.26, + "tests/integration/test_entity_icon.py": 34.95, + "tests/integration/test_fan_turn_on_action.py": 25.98, + "tests/integration/test_fnv1_hash_object_id.py": 4.85, + "tests/integration/test_fnv1a_hash.py": 5.14, + "tests/integration/test_gpio_expander_cache.py": 21.0, + "tests/integration/test_host_logger_thread_safety.py": 17.51, + "tests/integration/test_host_mode_basic.py": 21.2, + "tests/integration/test_host_mode_batch_delay.py": 26.68, + "tests/integration/test_host_mode_climate_basic_state.py": 18.04, + "tests/integration/test_host_mode_climate_control.py": 29.64, + "tests/integration/test_host_mode_empty_string_options.py": 28.8, + "tests/integration/test_host_mode_entity_fields.py": 28.68, + "tests/integration/test_host_mode_fan_preset.py": 16.98, + "tests/integration/test_host_mode_many_entities.py": 39.8, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12, + "tests/integration/test_host_mode_noise_encryption.py": 52.53, + "tests/integration/test_host_mode_reconnect.py": 14.48, + "tests/integration/test_host_mode_sensor.py": 17.38, + "tests/integration/test_host_ota.py": 94.96, + "tests/integration/test_host_preferences.py": 27.11, + "tests/integration/test_host_preferences_suspend_resume.py": 21.48, + "tests/integration/test_improv_serial_uart.py": 19.43, + "tests/integration/test_large_message_batching.py": 25.67, + "tests/integration/test_legacy_area.py": 15.59, + "tests/integration/test_legacy_climate_compat.py": 18.59, + "tests/integration/test_legacy_fan_compat.py": 18.34, + "tests/integration/test_light_automations.py": 16.74, + "tests/integration/test_light_binary_effect_off_phase.py": 57.7, + "tests/integration/test_light_calls.py": 25.88, + "tests/integration/test_light_constant_brightness.py": 22.32, + "tests/integration/test_light_control_action.py": 18.15, + "tests/integration/test_light_dim_relative_action.py": 30.35, + "tests/integration/test_light_effect_zero_brightness.py": 18.38, + "tests/integration/test_light_initial_state.py": 23.93, + "tests/integration/test_light_toggle_action.py": 26.16, + "tests/integration/test_lock_automations.py": 34.13, + "tests/integration/test_logger_buffered_recursion_guard.py": 25.77, + "tests/integration/test_loop_disable_enable.py": 14.71, + "tests/integration/test_loop_interval_decoupling.py": 26.16, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47, + "tests/integration/test_lvgl_headless_render.py": 96.36, + "tests/integration/test_micros_to_millis.py": 28.76, + "tests/integration/test_multi_click_trigger.py": 19.8, + "tests/integration/test_multi_device_preferences.py": 38.85, + "tests/integration/test_noise_encryption_key_protection.py": 25.81, + "tests/integration/test_object_id_api_verification.py": 28.46, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14, + "tests/integration/test_object_id_no_friendly_name.py": 18.82, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67, + "tests/integration/test_online_image_bmp.py": 7.41, + "tests/integration/test_oversized_payloads.py": 59.52, + "tests/integration/test_preference_key_stability.py": 27.24, + "tests/integration/test_runtime_stats.py": 20.53, + "tests/integration/test_safe_mode_loop_runs.py": 10.17, + "tests/integration/test_scheduler_blocking_warning.py": 51.45, + "tests/integration/test_scheduler_bulk_cleanup.py": 22.59, + "tests/integration/test_scheduler_defer_cancel.py": 17.64, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.61, + "tests/integration/test_scheduler_defer_fifo_simple.py": 24.73, + "tests/integration/test_scheduler_defer_stress.py": 23.91, + "tests/integration/test_scheduler_heap_stress.py": 25.77, + "tests/integration/test_scheduler_internal_id_no_collision.py": 19.83, + "tests/integration/test_scheduler_interval_reschedule.py": 23.4, + "tests/integration/test_scheduler_interval_zero_coerced.py": 5.11, + "tests/integration/test_scheduler_null_name.py": 17.36, + "tests/integration/test_scheduler_numeric_id_test.py": 20.81, + "tests/integration/test_scheduler_pool.py": 17.42, + "tests/integration/test_scheduler_rapid_cancellation.py": 25.28, + "tests/integration/test_scheduler_recursive_timeout.py": 16.48, + "tests/integration/test_scheduler_removed_item_race.py": 16.14, + "tests/integration/test_scheduler_self_keyed.py": 26.19, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26, + "tests/integration/test_scheduler_string_test.py": 27.09, + "tests/integration/test_script_array_params.py": 3.6, + "tests/integration/test_script_delay_params.py": 24.74, + "tests/integration/test_script_queued.py": 17.21, + "tests/integration/test_script_queued_idle_loop.py": 3.4, + "tests/integration/test_script_wait_on_boot.py": 23.7, + "tests/integration/test_sdl_headless_screenshot.py": 19.53, + "tests/integration/test_select_stringref_trigger.py": 18.93, + "tests/integration/test_sensor_filters_delta.py": 20.85, + "tests/integration/test_sensor_filters_ring_buffer.py": 16.82, + "tests/integration/test_sensor_filters_sliding_window.py": 54.78, + "tests/integration/test_sensor_filters_value_list.py": 19.46, + "tests/integration/test_sensor_timeout_filter.py": 18.39, + "tests/integration/test_set_internal_at_boot.py": 21.69, + "tests/integration/test_snapshot_display.py": 12.64, + "tests/integration/test_socket_wake_gate_tcp.py": 13.08, + "tests/integration/test_status_flags.py": 29.54, + "tests/integration/test_strftime_to.py": 25.62, + "tests/integration/test_syslog.py": 16.25, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18, + "tests/integration/test_template_climate_basic.py": 20.51, + "tests/integration/test_template_climate_custom_modes.py": 27.73, + "tests/integration/test_template_climate_nonoptimistic.py": 26.35, + "tests/integration/test_template_climate_on_control_ordering.py": 26.55, + "tests/integration/test_template_climate_publish_all_fields.py": 17.59, + "tests/integration/test_template_climate_sensor_push.py": 22.04, + "tests/integration/test_template_climate_set_actions.py": 16.82, + "tests/integration/test_template_climate_two_point_temperature.py": 25.13, + "tests/integration/test_template_text_save.py": 25.36, + "tests/integration/test_text_command.py": 18.79, + "tests/integration/test_text_sensor_raw_state.py": 17.07, + "tests/integration/test_uart_mock_ld2410.py": 59.58, + "tests/integration/test_uart_mock_ld2412.py": 59.4, + "tests/integration/test_uart_mock_ld2420.py": 45.27, + "tests/integration/test_uart_mock_ld2450.py": 27.96, + "tests/integration/test_uart_mock_modbus.py": 562.45, + "tests/integration/test_udp.py": 7.48, + "tests/integration/test_use_address_runtime.py": 17.27, + "tests/integration/test_valve_control_action.py": 18.33, + "tests/integration/test_varint_five_byte_device_id.py": 17.59, + "tests/integration/test_wait_until_mid_loop_timing.py": 16.93, + "tests/integration/test_wait_until_on_boot.py": 19.96, + "tests/integration/test_wait_until_ordering.py": 16.19, + "tests/integration/test_wait_until_reentrant_restart.py": 23.67, + "tests/integration/test_wake_loop_forces_phase_b.py": 17.58, + "tests/integration/test_water_heater_template.py": 21.96 } From 651863323b0b67626c91b798dacf51c7e29ac8bd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:40:00 +1000 Subject: [PATCH 152/759] [issues] Add AI usage guidance to the bug report template (#19122) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .github/ISSUE_TEMPLATE/bug_report.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c7..2244963a79d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true From 54b8e2e6dc7078657da75414fc8bd5b5967a8c59 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 09:56:14 -0400 Subject: [PATCH 153/759] [audio] Update esp-audio-libs to 4.0.0 (#19300) --- esphome/components/audio/__init__.py | 5 +---- esphome/components/mixer/speaker/mixer_speaker.cpp | 8 ++++---- esphome/components/mixer/speaker/mixer_speaker.h | 4 ++-- esphome/idf_component.yml | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2a5304be77e..14a08188949 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,10 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component( - name="esphome/esp-audio-libs", - ref="3.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") data = _get_data() diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index ef21da65c5a..7d33b6c49f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - esp_audio_libs::ducking::apply(audio_source->mutable_data(), - static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), - samples_to_duck, this->ducking_state_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d17826..494443d6951 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool pause_state_{false}; - esp_audio_libs::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index e817a253d9c..b3cd5ee09b5 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 3.2.1 + version: 4.0.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From df481eab006054ff6e7a00541cb762c82915d163 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:28:05 +0000 Subject: [PATCH 154/759] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 in /.github/actions/restore-python (#19313) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index ce14b0152a8..fa42372ac81 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 358e240d422c0474a170c2d587615cfa565a7224 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:14 -0500 Subject: [PATCH 155/759] Bump github/codeql-action/init from 4.37.9 to 4.38.0 (#19315) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aab3dea592c..ca70ec9c111 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 5d89480dbacf4883225a15d9636c2e5960703609 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:30:53 -0500 Subject: [PATCH 156/759] Bump github/codeql-action/analyze from 4.37.9 to 4.38.0 (#19314) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca70ec9c111..0daae69ccf0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: "/language:${{matrix.language}}" From 2c0e97421d088e73629972c3c9c5236abfe7e13f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:31:22 +0000 Subject: [PATCH 157/759] Bump astral-sh/setup-uv from 10.0.1 to 10.1.0 (#19312) Signed-off-by: dependabot[bot] --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 63219a1dbcd..c4c1ab072ff 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173d2c227ad..689baa1292e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -413,7 +413,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1274,7 +1274,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9100064176c..84d5e229d91 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 00dfe0f712419c3df5951a4cd42e82c9bcfd5e2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:33:20 +0000 Subject: [PATCH 158/759] Bump ruff from 0.16.6 to 0.16.7 (#19310) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95e6f0f73e4..0c7600ec127 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.6 + rev: v0.16.7 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index cd0427f33e1..010c8243e7e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.2 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From e66b59084239910b627fbcc784a93b0263908aec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:01 +0000 Subject: [PATCH 159/759] Bump platformdirs from 4.11.7 to 4.11.8 (#19309) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c73887a39d2..1bb8d04ac88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.7 # native esp-idf toolchain global cache dir +platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 5d713ad9ad3478732b1722f2e4d8464ee26ac0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:34:19 +0000 Subject: [PATCH 160/759] Bump filelock from 3.32.5 to 3.32.6 (#19311) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1bb8d04ac88..bfbf0aa321a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.8 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 588ad529e0e2ad263d131ad34311d2dedcdf67d6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 16:55:08 -0400 Subject: [PATCH 161/759] [i2s_audio] Ramp software volume changes (#19302) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 88 ++++++++----------- .../i2s_audio/speaker/i2s_audio_speaker.h | 25 ++++-- 2 files changed, 55 insertions(+), 58 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 9feaf39ffff..daef662a640 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -14,17 +14,19 @@ #include "esp_timer.h" -// esp-audio-libs -#include +#include namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. -// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +// Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in +// [-49.0, 0.0] dB; 0.0 is silence. static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; +// Rate at which the software gain moves toward a new target. +static constexpr uint32_t GAIN_RAMP_MS_PER_DB = 1; + void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -34,9 +36,10 @@ void I2SAudioSpeakerBase::setup() { return; } - // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // Initialize volume control. When audio_dac is configured, this sets the DAC volume and mute state. // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); + this->set_mute_state(this->mute_state_); } void I2SAudioSpeakerBase::dump_config() { @@ -136,6 +139,10 @@ void I2SAudioSpeakerBase::loop() { break; } + // Seed the ramp at the live target so this run adopts it instantly rather than fading to it + // from wherever the previous run left off. Posted here, not in the task: the ramp's mailbox + // allows one writer, and that is the main loop. + this->post_software_gain_(0); xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); @@ -153,50 +160,31 @@ void I2SAudioSpeakerBase::loop() { } void I2SAudioSpeakerBase::set_volume(float volume) { - this->volume_ = volume; -#ifdef USE_AUDIO_DAC - if (this->audio_dac_ != nullptr) { - if (volume > 0.0f) { - this->audio_dac_->set_mute_off(); - } - this->audio_dac_->set_volume(volume); - } else -#endif // USE_AUDIO_DAC - { - // Fallback to software volume control by using a Q31 fixed point scaling factor. - // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely - // and avoid any floating-point precision issues that could cause slight volume reduction. - if (volume >= 1.0f) { - this->q31_volume_factor_ = INT32_MAX; - } else if (volume <= 0.0f) { - this->q31_volume_factor_ = 0; - } else { - this->q31_volume_factor_ = - esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); - } - } + speaker::Speaker::set_volume(volume); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); } void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { - this->mute_state_ = mute_state; + speaker::Speaker::set_mute_state(mute_state); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); +} + +void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } else -#endif // USE_AUDIO_DAC - { - if (mute_state) { - // Fallback to software volume control and scale by 0 - this->q31_volume_factor_ = 0; - } else { - // Revert to previous volume when unmuting - this->set_volume(this->volume_); - } + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp stays at unity } +#endif // USE_AUDIO_DAC + // Software volume control. The ramp treats 0 dB as unity and skips processing there. + float target_db; + if (this->mute_state_ || this->volume_ <= 0.0f) { + target_db = -INFINITY; + } else if (this->volume_ >= 1.0f) { + target_db = 0.0f; + } else { + target_db = remap(this->volume_, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f); + } + this->gain_ramp_.set_target_db_at_rate(target_db, rate_samples); } size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { @@ -355,14 +343,14 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s } void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q31_volume_factor_ == INT32_MAX) { - return; // Max volume, no processing needed +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp is never targeted } - +#endif // USE_AUDIO_DAC const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + this->gain_ramp_.process(data, static_cast(bytes_per_sample), + this->current_stream_info_.bytes_to_samples(bytes_read)); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index adb6ca5e3f7..5812cc211b2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,6 +16,8 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include // esp-audio-libs + namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations @@ -77,19 +79,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool has_buffered_data() const override; - /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to convert the volume to a dB target for + /// the gain ramp. /// @param volume between 0.0 and 1.0 void set_volume(float volume) override; - /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Mutes or unmutes the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to post the mute state to the gain ramp. /// @param mute_state true for muting, false for unmuting void set_mute_state(bool mute_state) override; protected: + /// @brief Posts the ramp target derived from the current volume and mute state. No-op when an audio dac owns + /// volume. Main loop only. + /// @param rate_samples Samples the ramp takes per dB of change; 0 adopts the target at once + void post_software_gain_(uint32_t rate_samples); + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); @@ -128,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} - /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the + /// speaker task only. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); @@ -155,7 +162,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int32_t q31_volume_factor_{INT32_MAX}; + // Smooths software gain changes. The main loop posts targets, the speaker task processes; + // GainRamp's mailbox makes that safe. The main loop is the only poster. + esp_audio_libs::gain::GainRamp gain_ramp_; audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as From 328077c4f890923db9cd538101dc99ed8db89ad4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 14 Sep 2026 16:16:14 -0500 Subject: [PATCH 162/759] [tinyusb] Add on_mount/on_unmount triggers and is_mounted condition (#19067) Co-authored-by: Claude Fable 5.1 --- esphome/components/tinyusb/__init__.py | 52 ++++++++++++++++++- .../components/tinyusb/tinyusb_component.cpp | 34 +++++++++++- .../components/tinyusb/tinyusb_component.h | 27 ++++++++++ tests/components/tinyusb/common.yaml | 9 ++++ .../components/tinyusb/test.esp32-p4-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s2-idf.yaml | 8 ++- .../components/tinyusb/test.esp32-s3-idf.yaml | 8 ++- 7 files changed, 141 insertions(+), 5 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 53c4ab00734..7ad88d3018d 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -12,17 +12,22 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -33,6 +38,20 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -44,6 +63,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +124,28 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +@automation.register_condition( + "tinyusb.is_mounted", + IsMountedCondition, + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), +) +async def tinyusb_is_mounted_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb6..3fab9de0086 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21a..f7f574ec6d5 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,8 +1,11 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +26,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +48,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +63,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), @@ -69,6 +87,15 @@ class TinyUSB final : public Component { }; }; +template class IsMountedCondition final : public Condition { + public: + explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_mounted(); } + + protected: + TinyUSB *parent_; +}; + } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb/common.yaml b/tests/components/tinyusb/common.yaml index 674e89dbe87..32db1999afe 100644 --- a/tests/components/tinyusb/common.yaml +++ b/tests/components/tinyusb/common.yaml @@ -6,6 +6,15 @@ tinyusb: usb_product_str: ESPHomeTestProduct usb_serial_str: ESPHomeTestSerialNumber usb_vendor_id: 0x2345 + on_mount: + - logger.log: USB host mounted + - if: + condition: + tinyusb.is_mounted: + then: + - logger.log: USB host is mounted + on_unmount: + - logger.log: USB host unmounted # tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that. usb_cdc_acm: diff --git a/tests/components/tinyusb/test.esp32-p4-idf.yaml b/tests/components/tinyusb/test.esp32-p4-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-p4-idf.yaml +++ b/tests/components/tinyusb/test.esp32-p4-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 diff --git a/tests/components/tinyusb/test.esp32-s2-idf.yaml b/tests/components/tinyusb/test.esp32-s2-idf.yaml index 09b98ada401..67ea24f2c6c 100644 --- a/tests/components/tinyusb/test.esp32-s2-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s2-idf.yaml @@ -1,4 +1,10 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 # S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared # USB OTG peripheral; route the logger to UART0 so the fixture builds. diff --git a/tests/components/tinyusb/test.esp32-s3-idf.yaml b/tests/components/tinyusb/test.esp32-s3-idf.yaml index dade44d145b..7a37fcf41b8 100644 --- a/tests/components/tinyusb/test.esp32-s3-idf.yaml +++ b/tests/components/tinyusb/test.esp32-s3-idf.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +packages: + tinyusb: !include common.yaml + +# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the +# S31 would need the GPIO ISR path and rejects the key. +tinyusb: + vbus_monitor_pin: 4 From 2472838e130fe597f7326a0ec94712b92acb8b15 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 17:46:30 -0400 Subject: [PATCH 163/759] [speaker][speaker_source] Make a volume of zero silent with an audio DAC (#19305) --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/speaker/speaker.h | 34 ++++++++++++++----- .../speaker_source_media_player.cpp | 2 +- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index daef662a640..0c1140da0c6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -177,7 +177,7 @@ void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #endif // USE_AUDIO_DAC // Software volume control. The ramp treats 0 dB as unity and skips processing there. float target_db; - if (this->mute_state_ || this->volume_ <= 0.0f) { + if (this->is_silent_()) { target_db = -INFINITY; } else if (this->volume_ >= 1.0f) { target_db = 0.0f; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index fe994f440df..f40d0f4a1a3 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/speaker/speaker.h b/esphome/components/speaker/speaker.h index c89b6c588c7..01e9ca042e0 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -18,6 +18,9 @@ namespace esphome::speaker { +/// Volumes below this are treated as zero +static constexpr float SILENT_VOLUME_THRESHOLD = 0.001f; + enum State : uint8_t { STATE_STOPPED = 0, STATE_STARTING, @@ -65,13 +68,15 @@ class Speaker { bool is_running() const { return this->state_ == STATE_RUNNING; } bool is_stopped() const { return this->state_ == STATE_STOPPED; } - // Volume control is handled by a configured audio dac component. Individual speaker components can - // override and implement in software if an audio dac isn't available. + // Volume and mute are independent: changing one never alters the other's stored state. Volume control is + // handled by a configured audio dac component. Individual speaker components can override and implement in + // software if an audio dac isn't available. virtual void set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { this->audio_dac_->set_volume(volume); + this->apply_audio_dac_mute_(); } #endif }; @@ -80,13 +85,7 @@ class Speaker { virtual void set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } + this->apply_audio_dac_mute_(); #endif } virtual bool get_mute_state() { return this->mute_state_; } @@ -110,6 +109,23 @@ class Speaker { } protected: + /// @brief Whether the output should be silent: muted, or the volume is effectively zero. + /// Volume steps from media players can leave a positive value near float epsilon instead of exactly zero. + bool is_silent_() const { return this->mute_state_ || this->volume_ < SILENT_VOLUME_THRESHOLD; } + +#ifdef USE_AUDIO_DAC + /// @brief Uses the audio dac's mute as the silence mechanism, since a dac's minimum volume is often audible. + void apply_audio_dac_mute_() { + if (this->audio_dac_ == nullptr) + return; + if (this->is_silent_()) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } +#endif + State state_{STATE_STOPPED}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index a33a1a16509..661146ee49c 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001f) { + if (volume < speaker::SILENT_VOLUME_THRESHOLD) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); From 7a25ca074156203c979ac268f8248b90989c31d4 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Mon, 14 Sep 2026 17:11:25 -0500 Subject: [PATCH 164/759] [speaker_source] Don't remap a requested volume of zero (#19063) Co-authored-by: rexmoriarty <181678468+rexmoriarty@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 661146ee49c..cee203a6991 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -809,8 +809,11 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); for (auto &ps : this->pipelines_) { if (ps.is_configured()) { From 0af1f22e77985a91c229b8da6ca27f461ea7f993 Mon Sep 17 00:00:00 2001 From: Carrie Watts Date: Tue, 15 Sep 2026 00:57:57 +0200 Subject: [PATCH 165/759] [speaker_source] keep mute when volume changes (#18412) Signed-off-by: carriewattsmake Co-authored-by: carriewattsmake Co-authored-by: Kevin Ahrendt --- .../speaker_source/speaker_source_media_player.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index cee203a6991..215f3942d58 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -832,15 +832,6 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { } } - // Turn on the mute state if the volume is effectively zero, off otherwise. - // Pass publish=false to avoid saving twice. - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true, false); - } else { - this->set_mute_state_(false, false); - } - - // Save after mute mutation so the restored state has the correct is_muted_ value if (publish) { this->save_volume_restore_state_(); } From 8d61b35cdc5063fd6e267d85667963d98ec17fed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 14 Sep 2026 18:59:05 -0400 Subject: [PATCH 166/759] [speaker] Make media player volume and mute independent (#19307) --- .../speaker/media_player/speaker_media_player.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index f40d0f4a1a3..9ce50d7b762 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -595,8 +595,11 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit with in the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); if (this->media_speaker_ != nullptr) { this->media_speaker_->set_volume(bounded_volume); @@ -611,13 +614,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->save_volume_restore_state_(); } - // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < speaker::SILENT_VOLUME_THRESHOLD) { - this->set_mute_state_(true); - } else { - this->set_mute_state_(false); - } - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } From e737dc8daca325f459f0f8045d0fcc0c055761fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:33:24 +0000 Subject: [PATCH 167/759] Synchronise Device Classes from Home Assistant (#19318) --- esphome/components/binary_sensor/__init__.py | 2 ++ esphome/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 9ef7efc96a3..a114ab42051 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -81,6 +82,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, diff --git a/esphome/const.py b/esphome/const.py index e1d875f94bf..fd95df41965 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1345,6 +1345,7 @@ DEVICE_CLASS_GARAGE = "garage" DEVICE_CLASS_GARAGE_DOOR = "garage_door" DEVICE_CLASS_GAS = "gas" DEVICE_CLASS_GATE = "gate" +DEVICE_CLASS_GLASS_BREAK = "glass_break" DEVICE_CLASS_HEAT = "heat" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_IDENTIFY = "identify" From e163ae5299a4bcb61a904b450b8d38ca32895a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 168/759] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb59548..38cba56c623 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 81b90766b9e..a1a3436d470 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -690,7 +690,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -864,6 +873,8 @@ async def wifi_roam_to_code( KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -896,6 +907,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 00000000000..87f599c66e8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 00000000000..6973e6e26f8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 00000000000..2b4200968a7 --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 00000000000..20b69b6c64b --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From 6362ae71c09c29297c0b08c925be1cd15dd3076b Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 169/759] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed706..0a34ed037d5 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 -# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a821..037901a8733 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d5..1623c099a34 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb910664..66ddcd7722d 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9b..4c1d11da838 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e3..aa72a08a60b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb0..b8d06d3d5a3 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31e..741d4f6f00d 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af7..242e2eea218 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d74..c964ced987b 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab6..6fc8c8331cf 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a31..2c5b92b810b 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50b..1eafb131664 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825e..fcccae144e0 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 00000000000..01bdacbf863 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f4..592f6c12bad 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5b..3bdfa094e0e 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5cf..ce2965e449d 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..d6a29d7175b --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb73..b488e51f3c8 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..49e89eaa20f --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 457bb3ecc948a250a66497915154723342bb8e24 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 170/759] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From f19403192da7098c0b05acc9dacd20fffac22e29 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 11:47:06 -0500 Subject: [PATCH 171/759] [usb_uart] Add claim_comm_interface option (#18969) --- esphome/components/usb_uart/__init__.py | 53 +++++++++++++++++++++--- esphome/components/usb_uart/usb_uart.cpp | 21 ++++++---- esphome/components/usb_uart/usb_uart.h | 4 ++ tests/components/usb_uart/common.yaml | 1 + 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index edbf75f70f8..5d0f8be1655 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -44,6 +44,7 @@ UART_STOP_BITS_OPTIONS = { } DEFAULT_BAUD_RATE = 9600 +CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" class Type: @@ -56,6 +57,7 @@ class Type: max_channels: int = 1, baud_rate_required: bool = True, max_baud: int = 1_000_000, + has_comm_interface: bool = False, ) -> None: self.name = name cls = cls or name @@ -65,6 +67,9 @@ class Type: self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud + # True for types that claim the CDC comm (interrupt) interface; only these + # accept the claim_comm_interface option. + self.has_comm_interface = has_comm_interface @property def max_channels(self) -> int: @@ -80,11 +85,21 @@ class Type: uart_types = ( - Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type( + "CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False, has_comm_interface=True + ), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type( + "ESP_JTAG", + 0x303A, + 0x1001, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), @@ -95,12 +110,20 @@ uart_types = ( Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type( + "STM32_VCP", + 0x0483, + 0x5740, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), ) def channel_schema(type_: "Type") -> cv.Schema: - return cv.Schema( + schema = cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( cv.ensure_list( @@ -139,9 +162,26 @@ def channel_schema(type_: "Type") -> cv.Schema: max=type_.max_channels, msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", ), - ) + ), } ) + if type_.has_comm_interface: + # The comm (interrupt) interface pins a host hardware channel per device; + # disable to save one on channel-poor hosts (some devices may need it + # claimed before enabling data flow). + schema = schema.extend( + {cv.Optional(CONF_CLAIM_COMM_INTERFACE, default=True): cv.boolean} + ) + else: + schema = schema.extend( + { + cv.Optional(CONF_CLAIM_COMM_INTERFACE): cv.invalid( + f"'{CONF_CLAIM_COMM_INTERFACE}' is only supported on device types " + f"that claim the CDC comm interface; {type_.name} never claims it" + ) + } + ) + return schema CONFIG_SCHEMA = cv.ensure_list( @@ -172,6 +212,9 @@ async def to_code(config: list[ConfigType]) -> None: for device in config: var = await register_usb_client(device) + # The C++ default is true; only emit the override + if not device.get(CONF_CLAIM_COMM_INTERFACE, True): + cg.add(var.set_claim_comm_interface(False)) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 60b7fe4e9c4..3113f695f60 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -431,15 +431,20 @@ void USBUartTypeCdcAcm::on_connected() { // they enable data flow on the bulk endpoints. if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { - auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, - channel->cdc_dev_.interrupt_interface_number, 0); - if (err_comm != ESP_OK) { - // Continue anyway: the interface number stays valid for CDC request addressing - ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, - esp_err_to_name(err_comm)); + if (!this->claim_comm_interface_) { + ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", + channel->cdc_dev_.interrupt_interface_number); } else { - ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_claimed = true; + auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, + channel->cdc_dev_.interrupt_interface_number, 0); + if (err_comm != ESP_OK) { + // Continue anyway: the interface number stays valid for CDC request addressing + ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, + esp_err_to_name(err_comm)); + } else { + ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; + } } } auto err = diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 9d87bf964c0..22563209da3 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -271,12 +271,16 @@ class USBUartComponent : public usb_host::USBClient { class USBUartTypeCdcAcm : public USBUartComponent { public: USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + void set_claim_comm_interface(bool claim) { this->claim_comm_interface_ = claim; } protected: virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + // Each claimed interface pins one host hardware channel per endpoint; skipping + // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). + bool claim_comm_interface_{true}; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 5b23f9d685f..2e41fad1a1b 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -6,6 +6,7 @@ usb_uart: type: cdc_acm vid: 0x1234 pid: 0x5678 + claim_comm_interface: false channels: - id: channel_0_1 - id: uart_1 From 565dc8092c67aa7651086d3e37c7de70b7661d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:42:52 -0500 Subject: [PATCH 172/759] Update tzdata requirement from >=2026.3 to >=2026.4 (#19328) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfbf0aa321a..15ee7af7c86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 tzlocal==5.4.4 # from time -tzdata>=2026.3 # from time +tzdata>=2026.4 # from time pyserial==3.5 platformio==6.1.19 esptool==5.4.0 From 57860dc06c39b479e3167bf36361149c3b42cb4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Sep 2026 16:10:46 -0500 Subject: [PATCH 173/759] [core] Add register_simple_action and register_parented_action helpers (#19321) --- AGENTS.md | 15 +++- esphome/automation.py | 127 ++++++++++++++++++++++------ tests/unit_tests/test_automation.py | 125 ++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8db3cd3d624..448bf49114f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,7 +431,17 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + Register it without writing a builder: + ```python + automation.register_simple_action( + "my_component.do_something", MyAction, schema, synchronous=True + ) + ``` + The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a + no-argument constructor, `register_parented_action` for a class deriving from `Parented`, and + the `@automation.register_action(...)` decorator only when the builder must also set fields. + + Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. * **Conditions:** ```cpp @@ -443,7 +453,8 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; + `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. diff --git a/esphome/automation.py b/esphome/automation.py index 1689d29c42f..3ffda50c812 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -102,6 +102,101 @@ def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schem return CONDITION_REGISTRY.register(name, condition_type, schema) +async def _build_with_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(automation_id, template_arg, parent) + + +async def _build_without_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(automation_id, template_arg) + + +async def _build_parented( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(automation_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +def register_simple_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes the object named by ``config[CONF_ID]``. + + Use the ``register_action`` decorator instead when the builder must also set fields. + """ + register_action(name, action_type, schema, synchronous=synchronous)( + _build_with_parent + ) + + +def register_simple_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_simple_action``.""" + register_condition(name, condition_type, schema)(_build_with_parent) + + +def register_bare_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes no arguments.""" + register_action(name, action_type, schema, synchronous=synchronous)( + _build_without_parent + ) + + +def register_bare_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_bare_action``.""" + register_condition(name, condition_type, schema)(_build_without_parent) + + +def register_parented_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action deriving from ``Parented``. + + The object is constructed without arguments and ``set_parent()`` receives the object + named by ``config[CONF_ID]``. + """ + register_action(name, action_type, schema, synchronous=synchronous)(_build_parented) + + +def register_parented_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_parented_action``.""" + register_condition(name, condition_type, schema)(_build_parented) + + Action = cg.esphome_ns.class_("Action") Trigger = cg.esphome_ns.class_("Trigger") ACTION_REGISTRY = Registry() @@ -534,44 +629,20 @@ async def lambda_action_to_code( return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) -@register_action( +register_simple_action( "component.update", UpdateComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) -@register_action( +register_simple_action( "component.suspend", SuspendComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_suspend_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) @register_action( diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index a377cf185a8..07ea7533601 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1,7 +1,9 @@ """Tests for esphome.automation module.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, call, patch +from collections.abc import Callable, Generator +from functools import partial +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -12,9 +14,18 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + register_bare_action, + register_bare_condition, + register_parented_action, + register_parented_condition, + register_simple_action, + register_simple_condition, ) +import esphome.codegen as cg +from esphome.const import CONF_ID +from esphome.core import ID from esphome.cpp_generator import MockObj, RawExpression -from esphome.util import RegistryEntry +from esphome.util import Registry, RegistryEntry def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]: @@ -475,3 +486,111 @@ async def test_build_callback_automations_defaults( mock_build_callback.assert_called_once_with( parent, "add_on_press_callback", [], conf, forwarder=None ) + + +PARENT_ID = ID("my_component") +PARENT_OBJ = MockObj("parent", "->") +NEW_OBJ = MockObj("var", "->") +ACTION_TYPE = cg.esphome_ns.class_("MyAction") +CONDITION_TYPE = cg.esphome_ns.class_("MyCondition") +TEMPLATE_ARG = cg.TemplateArguments() + + +class MockCodegen(NamedTuple): + get_variable: AsyncMock + new_pvariable: MagicMock + register_parented: AsyncMock + + +@pytest.fixture +def mock_cg() -> Generator[MockCodegen]: + """Patch the codegen calls the shared builders make.""" + with ( + patch("esphome.codegen.get_variable", new_callable=AsyncMock) as get_variable, + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch( + "esphome.codegen.register_parented", new_callable=AsyncMock + ) as register_parented, + ): + get_variable.return_value = PARENT_OBJ + new_pvariable.return_value = NEW_OBJ + yield MockCodegen(get_variable, new_pvariable, register_parented) + + +@pytest.fixture +def registries() -> Generator[tuple[Registry, Registry]]: + """Patch both registries so registrations made by a test do not leak.""" + actions = Registry() + conditions = Registry() + with ( + patch("esphome.automation.ACTION_REGISTRY", actions), + patch("esphome.automation.CONDITION_REGISTRY", conditions), + ): + yield actions, conditions + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register", "is_action", "ctor_parent", "parented"), + [ + (partial(register_simple_action, synchronous=True), True, True, False), + (partial(register_bare_action, synchronous=True), True, False, False), + (partial(register_parented_action, synchronous=True), True, False, True), + (register_simple_condition, False, True, False), + (register_bare_condition, False, False, False), + (register_parented_condition, False, False, True), + ], + ids=[ + "simple_action", + "bare_action", + "parented_action", + "simple_condition", + "bare_condition", + "parented_condition", + ], +) +async def test_shared_builders( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + register: Callable[..., None], + is_action: bool, + ctor_parent: bool, + parented: bool, +) -> None: + """Each helper constructs the object and wires the parent the way its C++ shape needs.""" + actions, conditions = registries + type_id = ACTION_TYPE if is_action else CONDITION_TYPE + register("my.entry", type_id, {}) + entry = (actions if is_action else conditions)["my.entry"] + assert entry.type_id is type_id + config = {CONF_ID: PARENT_ID} if ctor_parent or parented else {} + + result = await entry.fun(config, ID("obj_1"), TEMPLATE_ARG, []) + + assert result is NEW_OBJ + if ctor_parent: + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.new_pvariable.assert_called_once_with( + ID("obj_1"), TEMPLATE_ARG, PARENT_OBJ + ) + else: + mock_cg.get_variable.assert_not_called() + mock_cg.new_pvariable.assert_called_once_with(ID("obj_1"), TEMPLATE_ARG) + if parented: + mock_cg.register_parented.assert_awaited_once_with(NEW_OBJ, PARENT_ID) + else: + mock_cg.register_parented.assert_not_called() + + +@pytest.mark.parametrize("synchronous", [True, False]) +def test_shared_builders_keep_synchronous_flag( + registries: tuple[Registry, Registry], synchronous: bool +) -> None: + """The synchronous flag reaches the registry entry unchanged.""" + actions, _ = registries + register_simple_action("my.simple", ACTION_TYPE, {}, synchronous=synchronous) + register_bare_action("my.bare", ACTION_TYPE, {}, synchronous=synchronous) + register_parented_action("my.parented", ACTION_TYPE, {}, synchronous=synchronous) + assert actions["my.simple"].synchronous is synchronous + assert actions["my.bare"].synchronous is synchronous + assert actions["my.parented"].synchronous is synchronous From 20d199ae2066f1bb723d56b86643fe46608ffea0 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sun, 13 Sep 2026 18:18:52 -0700 Subject: [PATCH 174/759] [pmsa003i] Fix read from uninitialized stack memory (#19053) --- esphome/components/pmsa003i/pmsa003i.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 15f5d3e8793..0b5c72a94d2 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -88,7 +88,11 @@ void PMSA003IComponent::update() { bool PMSA003IComponent::read_data_(PM25AQIData *data) { uint8_t buffer[COUNT_DATA_BYTES]; - this->read_bytes_raw(buffer, COUNT_DATA_BYTES); + const i2c::ErrorCode error = this->read(buffer, COUNT_DATA_BYTES); + if (error != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C error %d", error); + return false; + } // https://github.com/adafruit/Adafruit_PM25AQI From b7acd9c0dc4a390a0369579d46e4ef5e6d3d2e5c Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 13 Sep 2026 18:36:39 -0700 Subject: [PATCH 175/759] [template] Stop water heater republishing when a temperature is unknown (#19013) --- .../water_heater/template_water_heater.cpp | 10 ++++-- ...r_heater_template_unknown_temperature.yaml | 16 +++++++++ .../integration/test_water_heater_template.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/water_heater_template_unknown_temperature.yaml diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 092df6fdca3..9d6a3523d28 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -1,6 +1,8 @@ #include "template_water_heater.h" #include "esphome/core/log.h" +#include + namespace esphome::template_ { static const char *const TAG = "template.water_heater"; @@ -45,9 +47,12 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { void TemplateWaterHeater::loop() { bool changed = false; + // NAN is passed through so a source that has no value yet shows as unknown, but NAN never + // equals NAN, so an already-NAN value must not count as a change or it would republish forever. auto curr_temp = this->current_temperature_f_.call(); if (curr_temp.has_value()) { - if (*curr_temp != this->current_temperature_) { + if (*curr_temp != this->current_temperature_ && + !(std::isnan(*curr_temp) && std::isnan(this->current_temperature_))) { this->current_temperature_ = *curr_temp; changed = true; } @@ -55,7 +60,8 @@ void TemplateWaterHeater::loop() { auto target_temp = this->target_temperature_f_.call(); if (target_temp.has_value()) { - if (*target_temp != this->target_temperature_) { + if (*target_temp != this->target_temperature_ && + !(std::isnan(*target_temp) && std::isnan(this->target_temperature_))) { this->target_temperature_ = *target_temp; changed = true; } diff --git a/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml new file mode 100644 index 00000000000..a70ed25bd7f --- /dev/null +++ b/tests/integration/fixtures/water_heater_template_unknown_temperature.yaml @@ -0,0 +1,16 @@ +esphome: + name: wh-template-unknown-test +host: +api: +logger: + +water_heater: + - platform: template + id: unknown_boiler + name: Unknown Boiler + # Both temperatures stay unknown, as they do before an upstream component reports a value. + current_temperature: !lambda "return NAN;" + target_temperature: !lambda "return NAN;" + supported_modes: + - "off" + - eco diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index d63d1d69845..3d7f8851605 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -155,3 +155,36 @@ async def test_water_heater_template( client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO) eco_state = await wait_for_state() assert eco_state.mode == WaterHeaterMode.ECO + + +@pytest.mark.asyncio +async def test_water_heater_template_unknown_temperature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a template water heater whose temperature lambdas stay unknown. + + NAN never compares equal to itself, so a lambda that keeps returning NAN must not be + mistaken for a changed value and republish the state on every loop iteration. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + state_count = 0 + + def on_state(state: aioesphomeapi.EntityState) -> None: + nonlocal state_count + if isinstance(state, WaterHeaterState): + state_count += 1 + + entities, _ = await client.list_entities_services() + water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)] + assert len(water_heater_infos) == 1 + + client.subscribe_states(on_state) + + # Let the device run for a while; only the single initial state may arrive. + await asyncio.sleep(1.0) + assert state_count <= 1, ( + f"Expected at most 1 state publish, got {state_count} - " + "an unknown (NAN) temperature is republishing every loop" + ) From 076dc017ab05b54bb9a5f52fcd266774aec2731e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:31 +1200 Subject: [PATCH 176/759] [core] Mark filters, manual_ip and interlock as advanced (#19272) --- esphome/components/binary_sensor/__init__.py | 4 +- esphome/components/ethernet/__init__.py | 4 +- esphome/components/gpio/switch/__init__.py | 8 ++- esphome/components/sensor/__init__.py | 4 +- esphome/components/text_sensor/__init__.py | 4 +- esphome/components/wifi/__init__.py | 8 ++- .../test_advanced_visibility.py | 53 +++++++++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/config_validation/test_advanced_visibility.py diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1ab6f7103f7..9ef7efc96a3 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -452,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 0454440f142..3e7d345805c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -420,7 +420,9 @@ def _validate(config: ConfigType) -> ConfigType: BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_SCHEMA, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 2e0b0969bc7..766cdc4afb3 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -15,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 79d4ce5e0c0..3b632a1847f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -344,7 +344,9 @@ _SENSOR_SCHEMA = ( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 29399a51b72..5c8d71696f5 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -148,7 +148,9 @@ _TEXT_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index d4b39c029b7..61b687d787e 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -285,7 +285,9 @@ WIFI_NETWORK_BASE = cv.Schema( cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), cv.Optional(CONF_CHANNEL): validate_channel, - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, } ) @@ -484,7 +486,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), - cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, diff --git a/tests/component_tests/config_validation/test_advanced_visibility.py b/tests/component_tests/config_validation/test_advanced_visibility.py new file mode 100644 index 00000000000..f7e03743198 --- /dev/null +++ b/tests/component_tests/config_validation/test_advanced_visibility.py @@ -0,0 +1,53 @@ +"""Power-user fields are marked as advanced on the shared schemas. + +``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs +whose defaults suit nearly every user, so a schema-aware editor should keep +them behind its "advanced settings" disclosure rather than on the main form. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi +import esphome.config_validation as cv + + +def _markers(schema: cv.Schema) -> dict[str, object]: + s = schema + if hasattr(s, "validators"): + # cv.All -> the schema is the first validator. + s = s.validators[0] + return {str(k): k for k in s.schema} + + +def _gpio_switch_schema() -> cv.Schema: + return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA + + +@pytest.mark.parametrize( + ("label", "schema_factory", "fields"), + [ + ("sensor", sensor.sensor_schema, ["filters"]), + ("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]), + ("text_sensor", text_sensor.text_sensor_schema, ["filters"]), + ("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]), + ("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]), + ("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]), + ("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]), + ], +) +def test_power_user_fields_are_advanced( + label: str, schema_factory, fields: list[str] +) -> None: + markers = _markers(schema_factory()) + for field in fields: + assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}" + + +def test_interlock_wait_time_keeps_its_default() -> None: + """Marking the field advanced must not drop its default.""" + markers = _markers(_gpio_switch_schema()) + assert markers["interlock_wait_time"].default() == "0ms" From 8c999d3152c13c622666e328bb98fcf50a949c24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:44:36 -0500 Subject: [PATCH 177/759] [number] Fix the default mode check so mode auto is no longer emitted (#19231) --- esphome/components/number/__init__.py | 14 ++++++---- esphome/components/number/number_traits.h | 2 +- tests/component_tests/number/__init__.py | 0 tests/component_tests/number/config/mode.yaml | 28 +++++++++++++++++++ tests/component_tests/number/test_number.py | 16 +++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/number/__init__.py create mode 100644 tests/component_tests/number/config/mode.yaml create mode 100644 tests/component_tests/number/test_number.py diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ea0c2d77f66..fc0893323be 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -174,6 +174,10 @@ NumberInRangeCondition = number_ns.class_( NumberMode = number_ns.enum("NumberMode") +# Schema default that also matches the C++ initializer in number_traits.h; codegen +# skips the setter when the config equals it. +DEFAULT_MODE = "AUTO" + NUMBER_MODES = { "AUTO": NumberMode.NUMBER_MODE_AUTO, "BOX": NumberMode.NUMBER_MODE_BOX, @@ -216,7 +220,7 @@ _NUMBER_SCHEMA = ( CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED ): validate_unit_of_measurement, cv.Optional( - CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + CONF_MODE, default=DEFAULT_MODE, visibility=cv.Visibility.ADVANCED ): cv.enum(NUMBER_MODES, upper=True), cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED @@ -286,10 +290,10 @@ async def setup_number_core_( cg.add(var.traits.set_max_value(max_value)) cg.add(var.traits.set_step(step)) - # Only set if non-default to avoid bloating setup() function - # (mode_ is initialized to NUMBER_MODE_AUTO in the header) - if config[CONF_MODE] != NumberMode.NUMBER_MODE_AUTO: - cg.add(var.traits.set_mode(config[CONF_MODE])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_MODE). + # The validated value is the enum key string, not the C++ enum expression. + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.traits.set_mode(mode)) CORE.add_job(_build_number_automations, var, config) diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index f855813c9bf..3c7942b9a36 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -31,7 +31,7 @@ class NumberTraits { float min_value_ = NAN; float max_value_ = NAN; float step_ = NAN; - NumberMode mode_{NUMBER_MODE_AUTO}; + NumberMode mode_{NUMBER_MODE_AUTO}; // Keep in sync with DEFAULT_MODE in __init__.py }; } // namespace esphome::number diff --git a/tests/component_tests/number/__init__.py b/tests/component_tests/number/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/number/config/mode.yaml b/tests/component_tests/number/config/mode.yaml new file mode 100644 index 00000000000..b3eae34436f --- /dev/null +++ b/tests/component_tests/number/config/mode.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +number: + - platform: template + id: auto_number + min_value: 0 + max_value: 10 + step: 1 + optimistic: true + - platform: template + id: box_number + min_value: 0 + max_value: 10 + step: 1 + mode: box + optimistic: true + - platform: template + id: explicit_auto_number + min_value: 0 + max_value: 10 + step: 1 + mode: auto + optimistic: true diff --git a/tests/component_tests/number/test_number.py b/tests/component_tests/number/test_number.py new file mode 100644 index 00000000000..b33508602af --- /dev/null +++ b/tests/component_tests/number/test_number.py @@ -0,0 +1,16 @@ +"""Tests for the number component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_mode_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Mode auto is the C++ initializer, so only a non default mode is set.""" + main_cpp = generate_main(component_config_path("mode.yaml")) + + assert "auto_number->traits.set_mode(" not in main_cpp + assert "explicit_auto_number->traits.set_mode(" not in main_cpp + assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp From 6582c618f1469940b1a4e88418b15e3834375b63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:00 -0500 Subject: [PATCH 178/759] [web_server] Skip setters that pass the default port, log and include internal values (#19226) --- esphome/components/web_server/__init__.py | 20 ++++++++--- .../web_server_base/web_server_base.h | 2 +- .../web_server/config/bare.yaml | 12 +++++++ .../web_server/config/custom.yaml | 15 ++++++++ .../web_server/config/defaults.yaml | 15 ++++++++ .../web_server/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/web_server/config/bare.yaml create mode 100644 tests/component_tests/web_server/config/custom.yaml create mode 100644 tests/component_tests/web_server/config/defaults.yaml create mode 100644 tests/component_tests/web_server/test_default_setters.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index a50c14a2f72..2459163786d 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -56,6 +56,10 @@ CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" CONF_ALLOWED_ORIGINS = "allowed_origins" +# Schema default that also matches the C++ initializer in web_server_base.h; codegen +# skips the setter when the config equals it. +DEFAULT_PORT = 80 + web_server_ns = cg.esphome_ns.namespace("web_server") WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) @@ -251,7 +255,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WebServer), - cv.Optional(CONF_PORT, default=80): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, cv.Optional(CONF_VERSION, default=2): cv.one_of(1, 2, 3, int=True), cv.Optional(CONF_CSS_URL): cv.string, cv.Optional(CONF_CSS_INCLUDE): cv.file_, @@ -379,9 +383,11 @@ async def to_code(config: ConfigType) -> None: version = config[CONF_VERSION] - cg.add(paren.set_port(config[CONF_PORT])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_PORT). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") - cg.add_define("USE_WEBSERVER_PORT", config[CONF_PORT]) + cg.add_define("USE_WEBSERVER_PORT", port) cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. @@ -395,9 +401,11 @@ async def to_code(config: ConfigType) -> None: # Captive portal will still be able to perform OTA updates even when this is set if config.get(CONF_OTA) is False: cg.add_define("USE_WEBSERVER_OTA_DISABLED") - cg.add(var.set_expose_log(config[CONF_LOG])) + # expose_log_ is true in C++; only emit the setter to turn it off. if config[CONF_LOG]: request_log_listener() # Request a log listener slot for web server log streaming + else: + cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: @@ -433,7 +441,9 @@ async def to_code(config: ConfigType) -> None: path = CORE.relative_config_path(config[CONF_JS_INCLUDE]) with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) - cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) + # include_internal_ is false in C++; only emit the setter to turn it on. + if config[CONF_INCLUDE_INTERNAL]: + cg.add(var.set_include_internal(True)) if CONF_LOCAL in config and config[CONF_LOCAL]: cg.add_define("USE_WEBSERVER_LOCAL") if config[CONF_COMPRESSION] == "gzip": diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 94579de70f8..72d3bf75b1c 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -170,7 +170,7 @@ class WebServerBase final { protected: uint8_t initialized_{0}; - uint16_t port_{80}; + uint16_t port_{80}; // Keep in sync with DEFAULT_PORT in web_server/__init__.py AsyncWebServer *server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH diff --git a/tests/component_tests/web_server/config/bare.yaml b/tests/component_tests/web_server/config/bare.yaml new file mode 100644 index 00000000000..dae1c488832 --- /dev/null +++ b/tests/component_tests/web_server/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: diff --git a/tests/component_tests/web_server/config/custom.yaml b/tests/component_tests/web_server/config/custom.yaml new file mode 100644 index 00000000000..2d37d7ae19d --- /dev/null +++ b/tests/component_tests/web_server/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 8080 + log: false + include_internal: true diff --git a/tests/component_tests/web_server/config/defaults.yaml b/tests/component_tests/web_server/config/defaults.yaml new file mode 100644 index 00000000000..3c34da43ac1 --- /dev/null +++ b/tests/component_tests/web_server/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + port: 80 + log: true + include_internal: false diff --git a/tests/component_tests/web_server/test_default_setters.py b/tests/component_tests/web_server/test_default_setters.py new file mode 100644 index 00000000000..2b13ed966b5 --- /dev/null +++ b/tests/component_tests/web_server/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that web_server only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 80, log on and include_internal off already live in the C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_port(" not in main_cpp + assert "set_expose_log(" not in main_cpp + assert "set_include_internal(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_port(8080);" in main_cpp + assert "set_expose_log(false);" in main_cpp + assert "set_include_internal(true);" in main_cpp From eae6af437bae1253cf4dbf85afc14c4a7d62a4fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:50:56 -0500 Subject: [PATCH 179/759] [output] Skip the power limit setters when they match the defaults (#19225) --- esphome/components/output/__init__.py | 13 +++++--- esphome/components/output/float_output.h | 1 + tests/component_tests/output/__init__.py | 0 .../config/ac_dimmer_min_power_zero.yaml | 13 ++++++++ .../output/config/power_limits.yaml | 18 +++++++++++ tests/component_tests/output/test_output.py | 31 +++++++++++++++++++ tests/components/ac_dimmer/common.yaml | 1 + 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/output/__init__.py create mode 100644 tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml create mode 100644 tests/component_tests/output/config/power_limits.yaml create mode 100644 tests/component_tests/output/test_output.py diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 4f6c8943f5e..10d5e5eb593 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -53,12 +53,17 @@ async def setup_output_platform_(obj, config): if CONF_POWER_SUPPLY in config: power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) - if CONF_MAX_POWER in config: + # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when + # the config matches them. The define stays whenever the key is present because + # platforms such as ac_dimmer read the scaling fields directly. + if (max_power := config.get(CONF_MAX_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_max_power(config[CONF_MAX_POWER])) - if CONF_MIN_POWER in config: + if max_power != 1.0: + cg.add(obj.set_max_power(max_power)) + if (min_power := config.get(CONF_MIN_POWER)) is not None: cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - cg.add(obj.set_min_power(config[CONF_MIN_POWER])) + if min_power != 0.0: + cg.add(obj.set_min_power(min_power)) # Only emit when zero_means_zero is actually enabled. The schema defaults to False # so this key is always present; emitting unconditionally would force # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 673f4235728..57c8c553f65 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -123,6 +123,7 @@ class FloatOutput : public BinaryOutput { virtual void write_state(float state) = 0; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING + // Codegen skips the setters for these values; keep in sync with output/__init__.py float max_power_{1.0f}; float min_power_{0.0f}; bool zero_means_zero_{false}; diff --git a/tests/component_tests/output/__init__.py b/tests/component_tests/output/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml new file mode 100644 index 00000000000..84c5eafc5ab --- /dev/null +++ b/tests/component_tests/output/config/ac_dimmer_min_power_zero.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ac_dimmer + id: dimmer + gate_pin: GPIO4 + zero_cross_pin: GPIO5 + min_power: 0% diff --git a/tests/component_tests/output/config/power_limits.yaml b/tests/component_tests/output/config/power_limits.yaml new file mode 100644 index 00000000000..682ae9de511 --- /dev/null +++ b/tests/component_tests/output/config/power_limits.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: default_power + pin: GPIO4 + max_power: 100% + min_power: 0% + - platform: ledc + id: custom_power + pin: GPIO5 + max_power: 90% + min_power: 1% diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py new file mode 100644 index 00000000000..172715aef08 --- /dev/null +++ b/tests/component_tests/output/test_output.py @@ -0,0 +1,31 @@ +"""Tests for the output platform codegen.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_default_power_limits_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """max_power 100% and min_power 0% already live in the C++ initializers.""" + main_cpp = generate_main(component_config_path("power_limits.yaml")) + + assert "default_power->set_max_power(" not in main_cpp + assert "default_power->set_min_power(" not in main_cpp + assert "custom_power->set_max_power(0.9f);" in main_cpp + assert "custom_power->set_min_power(0.01f);" in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} + + +def test_default_min_power_keeps_scaling_fields( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%.""" + main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml")) + + assert "dimmer->set_min_power(" not in main_cpp + assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines} diff --git a/tests/components/ac_dimmer/common.yaml b/tests/components/ac_dimmer/common.yaml index c16e2e834a9..8fa62c0636b 100644 --- a/tests/components/ac_dimmer/common.yaml +++ b/tests/components/ac_dimmer/common.yaml @@ -4,3 +4,4 @@ output: gate_pin: ${gate_pin} zero_cross_pin: ${zero_cross_pin} zero_cross_interrupt_type: ANY + min_power: 0% From 81be397056c7edd6e2e506d0e887fcea8bb07dd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:51:42 -0500 Subject: [PATCH 180/759] [light] Skip the flash transition setter and the empty effect list (#19228) --- esphome/components/light/__init__.py | 14 +++++++-- esphome/components/light/light_state.h | 2 +- .../light/config/transitions.yaml | 29 +++++++++++++++++++ .../light/test_default_setters.py | 19 ++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/light/config/transitions.yaml create mode 100644 tests/component_tests/light/test_default_setters.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dbcc28d64a3..ab9624c3649 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -340,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -387,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -502,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -514,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 3a3f8fc368c..eafa161f51e 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -356,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/tests/component_tests/light/config/transitions.yaml b/tests/component_tests/light/config/transitions.yaml new file mode 100644 index 00000000000..ecb33b0ea80 --- /dev/null +++ b/tests/component_tests/light/config/transitions.yaml @@ -0,0 +1,29 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + - platform: ledc + id: out_b + pin: GPIO5 + +light: + - platform: monochromatic + id: plain_light + output: out_a + flash_transition_length: 0s + - platform: monochromatic + id: fancy_light + output: out_b + flash_transition_length: 500ms + effects: + - pulse: + - platform: monochromatic + id: bare_light + output: out_a diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py new file mode 100644 index 00000000000..a4fc24a7cbf --- /dev/null +++ b/tests/component_tests/light/test_default_setters.py @@ -0,0 +1,19 @@ +"""Tests that light codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flash_length_and_empty_effects_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A 0 ms flash transition and an empty effect list match the C++ defaults.""" + main_cpp = generate_main(component_config_path("transitions.yaml")) + + assert "plain_light->set_flash_transition_length(" not in main_cpp + assert "plain_light->add_effects(" not in main_cpp + assert "bare_light->set_flash_transition_length(" not in main_cpp + assert "bare_light->add_effects(" not in main_cpp + assert "fancy_light->set_flash_transition_length(500);" in main_cpp + assert "fancy_light->add_effects({" in main_cpp From 808b7210db14d8610c653605d31eff86a626b29d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:52:57 -0500 Subject: [PATCH 181/759] [wifi] Skip setters that pass the default priority, timeouts, power save and auth mode (#19229) --- esphome/components/wifi/__init__.py | 28 +++++++++---- esphome/components/wifi/wifi_component.h | 4 +- tests/component_tests/wifi/__init__.py | 0 tests/component_tests/wifi/config/bare.yaml | 12 ++++++ tests/component_tests/wifi/config/custom.yaml | 18 +++++++++ .../component_tests/wifi/config/defaults.yaml | 18 +++++++++ .../wifi/test_default_setters.py | 39 +++++++++++++++++++ 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/wifi/__init__.py create mode 100644 tests/component_tests/wifi/config/bare.yaml create mode 100644 tests/component_tests/wifi/config/custom.yaml create mode 100644 tests/component_tests/wifi/config/defaults.yaml create mode 100644 tests/component_tests/wifi/test_default_setters.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 61b687d787e..418e1a49794 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -167,6 +167,9 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_REBOOT_TIMEOUT = "15min" +# Both defaults also match the C++ initializers in wifi_component.h; codegen skips +# the setter when the config equals them. wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") @@ -493,7 +496,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AP): wifi_network_ap, cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.SplitDefault( CONF_POWER_SAVE_MODE, @@ -603,7 +606,8 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_channel(config[CONF_CHANNEL])) if static_ip is not None: cg.add(ap.set_manual_ip(manual_ip(static_ip))) - if CONF_PRIORITY in config: + # priority_ is 0 in C++; skip the setter when the config matches it. + if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) return ap @@ -652,7 +656,9 @@ async def to_code(config): WiFiAP(), lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) - cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + # Skip the setter when the config matches the C++ initializer. + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which @@ -668,10 +674,18 @@ async def to_code(config): if has_manual_ip: cg.add_define("USE_WIFI_MANUAL_IP") - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) - if CONF_MIN_AUTH_MODE in config: - cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) + # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum + # auth WPA2; skip the setters when the config matches them. + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": + cg.add(var.set_power_save_mode(power_save_mode)) + if ( + min_auth_mode := config.get(CONF_MIN_AUTH_MODE) + ) is not None and min_auth_mode != "WPA2": + cg.add(var.set_min_auth_mode(min_auth_mode)) fast_connect = config[CONF_FAST_CONNECT] if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 16b62a5bb0e..8bf45814130 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -913,11 +913,11 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; - uint32_t reboot_timeout_{}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP - uint32_t ap_timeout_{}; + uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py #endif // 1-byte enums and integers diff --git a/tests/component_tests/wifi/__init__.py b/tests/component_tests/wifi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/wifi/config/bare.yaml b/tests/component_tests/wifi/config/bare.yaml new file mode 100644 index 00000000000..94e5de47a0f --- /dev/null +++ b/tests/component_tests/wifi/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/config/custom.yaml b/tests/component_tests/wifi/config/custom.yaml new file mode 100644 index 00000000000..068479a5404 --- /dev/null +++ b/tests/component_tests/wifi/config/custom.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 5 + ap: + ssid: fallback + ap_timeout: 2min + reboot_timeout: 0s + power_save_mode: light + min_auth_mode: wpa diff --git a/tests/component_tests/wifi/config/defaults.yaml b/tests/component_tests/wifi/config/defaults.yaml new file mode 100644 index 00000000000..1b5e7d7dba9 --- /dev/null +++ b/tests/component_tests/wifi/config/defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + networks: + - ssid: test + password: testtest + priority: 0 + ap: + ssid: fallback + ap_timeout: 90s + reboot_timeout: 15min + power_save_mode: none + min_auth_mode: wpa2 diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py new file mode 100644 index 00000000000..b326f3eaeeb --- /dev/null +++ b/tests/component_tests/wifi/test_default_setters.py @@ -0,0 +1,39 @@ +"""Tests that wifi codegen skips setters for default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "set_priority(" not in main_cpp + assert "set_ap_timeout(" not in main_cpp + assert "set_reboot_timeout(" not in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "set_min_auth_mode(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_priority(5);" in main_cpp + assert "set_ap_timeout(120000);" in main_cpp + assert "set_reboot_timeout(0);" in main_cpp + assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp + assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp From f8bda9fbad897d10aadf847ea2fd03fea20cb125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Sep 2026 23:53:41 -0500 Subject: [PATCH 182/759] [logger] Skip the hardware UART setter when it matches the default (#19230) --- esphome/components/logger/__init__.py | 13 ++++---- esphome/components/logger/logger.h | 4 +-- tests/component_tests/logger/test_logger.py | 32 +++++++++++++++++++ .../logger/test_logger_libretiny_default.yaml | 8 +++++ .../logger/test_logger_libretiny_uart0.yaml | 9 ++++++ .../logger/test_logger_uart1.yaml | 9 ++++++ 6 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_libretiny_default.yaml create mode 100644 tests/component_tests/logger/test_logger_libretiny_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_uart1.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 07b8b030840..138db75ad10 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -362,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 9c26814f7ec..ae55f4145a9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -352,10 +352,10 @@ class Logger final : public Component { // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 94a6f7ac7bc..4ce30afb946 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main): f"Component allocation '{alloc.group()}' at position {alloc.start()} " f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) + + +def test_default_uart_selection_is_not_emitted(generate_main): + """UART0 is the C++ initializer on ESP8266, so the setter is skipped.""" + main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "set_uart_selection(" not in main_cpp + + +def test_custom_uart_selection_is_emitted(generate_main): + """A non default UART still reaches the setter before pre_setup().""" + main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml") + + assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp + + +def test_libretiny_default_uart_selection_is_not_emitted(generate_main): + """DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_default.yaml" + ) + + assert "set_uart_selection(" not in main_cpp + + +def test_libretiny_uart0_is_emitted(generate_main): + """UART0 is not the LibreTiny initializer, so it must still be set.""" + main_cpp = generate_main( + "tests/component_tests/logger/test_logger_libretiny_uart0.yaml" + ) + + assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp diff --git a/tests/component_tests/logger/test_logger_libretiny_default.yaml b/tests/component_tests/logger/test_logger_libretiny_default.yaml new file mode 100644 index 00000000000..1f11ea4580c --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_default.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/tests/component_tests/logger/test_logger_libretiny_uart0.yaml b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml new file mode 100644 index 00000000000..dc25fe99ce2 --- /dev/null +++ b/tests/component_tests/logger/test_logger_libretiny_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_uart1.yaml b/tests/component_tests/logger/test_logger_uart1.yaml new file mode 100644 index 00000000000..ce45a6ae3fb --- /dev/null +++ b/tests/component_tests/logger/test_logger_uart1.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + hardware_uart: UART1 From a1ad794d036976c3122d735ec7cffa82d8e0fb29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Sep 2026 01:05:27 -0500 Subject: [PATCH 183/759] [esp8266_pwm] Skip the frequency setter when it matches the default (#19224) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/esp8266_pwm/output.py | 10 ++++++++-- tests/component_tests/esp8266_pwm/__init__.py | 0 .../esp8266_pwm/config/frequency.yaml | 19 +++++++++++++++++++ .../esp8266_pwm/test_esp8266_pwm.py | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp8266_pwm/__init__.py create mode 100644 tests/component_tests/esp8266_pwm/config/frequency.yaml create mode 100644 tests/component_tests/esp8266_pwm/test_esp8266_pwm.py diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..79c2e509848 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index dd151a3e044..be6e63b154d 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -22,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -29,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -48,7 +52,9 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( diff --git a/tests/component_tests/esp8266_pwm/__init__.py b/tests/component_tests/esp8266_pwm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266_pwm/config/frequency.yaml b/tests/component_tests/esp8266_pwm/config/frequency.yaml new file mode 100644 index 00000000000..9ffc8af736e --- /dev/null +++ b/tests/component_tests/esp8266_pwm/config/frequency.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +output: + - platform: esp8266_pwm + id: default_frequency + pin: GPIO4 + frequency: 1kHz + - platform: esp8266_pwm + id: custom_frequency + pin: GPIO5 + frequency: 2kHz + - platform: esp8266_pwm + id: schema_default_frequency + pin: GPIO12 diff --git a/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py new file mode 100644 index 00000000000..771e5133459 --- /dev/null +++ b/tests/component_tests/esp8266_pwm/test_esp8266_pwm.py @@ -0,0 +1,16 @@ +"""Tests for the esp8266_pwm output codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_frequency_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The 1 kHz default already lives in the C++ initializer.""" + main_cpp = generate_main(component_config_path("frequency.yaml")) + + assert "default_frequency->set_frequency(" not in main_cpp + assert "schema_default_frequency->set_frequency(" not in main_cpp + assert "custom_frequency->set_frequency(2000.0f);" in main_cpp From 45362dbc5b6ca982f0d1747bd2d2239461de89da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Tue, 15 Sep 2026 16:15:06 +0300 Subject: [PATCH 184/759] [bk72xx_ble] Keep wifi power save off while BLE is compiled in (#19317) --- esphome/components/bk72xx_ble/__init__.py | 11 ++++- esphome/components/wifi/__init__.py | 32 ++++++++++++- .../bk72xx_ble/config/test_power_save.yaml | 12 +++++ .../bk72xx_ble/test_power_save.py | 20 ++++++++ .../wifi/test_power_save_off.py | 46 +++++++++++++++++++ .../validate-power-save.bk72xx-ard.yaml | 9 ++++ 6 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/bk72xx_ble/config/test_power_save.yaml create mode 100644 tests/component_tests/bk72xx_ble/test_power_save.py create mode 100644 tests/component_tests/wifi/test_power_save_off.py create mode 100644 tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 74b9cb59548..38cba56c623 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -23,7 +23,7 @@ public ble_api.h. import logging import esphome.codegen as cg -from esphome.components import libretiny +from esphome.components import libretiny, wifi from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, @@ -84,6 +84,15 @@ def _final_validate(config: ConfigType) -> None: # which run on a BLE 4.2 board. The hard error is raised at codegen. if msg := _unsupported_family_message(libretiny.get_libretiny_family()): _LOGGER.warning("%s (this configuration cannot compile)", msg) + # Any wifi power_save_mode other than NONE also arms the Beken SDK's MCU + # sleep. With the BLE controller running, that sleep never wakes up once the + # station is stopped (adapter restart after failed roams, wifi.disable): the + # device is dead until a power cycle (esphome#18592). Keep power save off + # until LibreTiny ships the SDK-side fix (libretiny-eu/libretiny#414). + wifi.force_power_save_off( + "with BLE running, the Beken SDK's MCU sleep halts the device once the " + "station is stopped (https://github.com/esphome/esphome/issues/18592)" + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 418e1a49794..64eef46f742 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -681,7 +681,16 @@ async def to_code(config): ): cg.add(var.set_reboot_timeout(reboot_timeout)) if (power_save_mode := config[CONF_POWER_SAVE_MODE]) != "NONE": - cg.add(var.set_power_save_mode(power_save_mode)) + if reasons := CORE.data.get(POWER_SAVE_OFF_REASONS_KEY): + _LOGGER.warning( + "power_save_mode %s is not applied: %s", + power_save_mode, + "; ".join(reasons), + ) + else: + cg.add(var.set_power_save_mode(power_save_mode)) + # From here on force_power_save_off() can no longer take effect + CORE.data[POWER_SAVE_APPLIED_KEY] = True if ( min_auth_mode := config.get(CONF_MIN_AUTH_MODE) ) is not None and min_auth_mode != "WPA2": @@ -843,6 +852,8 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +POWER_SAVE_OFF_REASONS_KEY = "wifi_power_save_off_reasons" +POWER_SAVE_APPLIED_KEY = "wifi_power_save_applied" RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" @@ -875,6 +886,25 @@ def request_wifi_scan_results_lock() -> None: CORE.data[SCAN_RESULTS_LOCK_KEY] = True +def force_power_save_off(reason: str) -> None: + """Keep the station out of WiFi power save regardless of power_save_mode. + + Components whose platform cannot run power save safely call this from their + final validation (FINAL_VALIDATE_SCHEMA), which always runs before any code + generation. Every distinct reason is kept; when the configured mode is not + NONE, wifi's code generation logs them and skips the mode. Calling it once + wifi has generated its code is too late and raises. + """ + if POWER_SAVE_APPLIED_KEY in CORE.data: + raise EsphomeError( + "wifi.force_power_save_off() must be called from final validation, " + "before wifi generates its code" + ) + reasons: list[str] = CORE.data.setdefault(POWER_SAVE_OFF_REASONS_KEY, []) + if reason not in reasons: + reasons.append(reason) + + def enable_runtime_power_save_control(): """Enable runtime WiFi power save control. diff --git a/tests/component_tests/bk72xx_ble/config/test_power_save.yaml b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml new file mode 100644 index 00000000000..87f599c66e8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/config/test_power_save.yaml @@ -0,0 +1,12 @@ +esphome: + name: bk-power-save + +bk72xx: + board: cb2s + +wifi: + ssid: test + password: testtest + power_save_mode: high + +bk72xx_ble: diff --git a/tests/component_tests/bk72xx_ble/test_power_save.py b/tests/component_tests/bk72xx_ble/test_power_save.py new file mode 100644 index 00000000000..6973e6e26f8 --- /dev/null +++ b/tests/component_tests/bk72xx_ble/test_power_save.py @@ -0,0 +1,20 @@ +"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not +wake up once the station is stopped while the BLE controller runs.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_power_save_mode_is_not_applied_with_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("test_power_save.yaml")) + + assert "bk72xx_ble::BK72xxBLE" in main_cpp + assert "set_power_save_mode(" not in main_cpp + assert "power_save_mode HIGH is not applied" in caplog.text + assert "issues/18592" in caplog.text diff --git a/tests/component_tests/wifi/test_power_save_off.py b/tests/component_tests/wifi/test_power_save_off.py new file mode 100644 index 00000000000..2b4200968a7 --- /dev/null +++ b/tests/component_tests/wifi/test_power_save_off.py @@ -0,0 +1,46 @@ +"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the +station out of power save.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import wifi +from esphome.core import CORE, EsphomeError + + +def test_reasons_accumulate_without_duplicates() -> None: + """Every caller's reason is kept once; a repeated reason is not duplicated.""" + wifi.force_power_save_off("first") + wifi.force_power_save_off("first") + wifi.force_power_save_off("second") + + assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"] + + +def test_forced_off_skips_the_setter_and_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """With a reason recorded, power_save_mode is reported and not applied.""" + wifi.force_power_save_off("the platform cannot sleep") + + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "set_power_save_mode(" not in main_cpp + assert ( + "power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text + ) + + +def test_call_after_wifi_codegen_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Once wifi has generated its code the hook cannot take effect any more.""" + generate_main(component_config_path("custom.yaml")) + + with pytest.raises(EsphomeError, match="before wifi generates its code"): + wifi.force_power_save_off("too late") diff --git a/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml new file mode 100644 index 00000000000..20b69b6c64b --- /dev/null +++ b/tests/components/bk72xx_ble/validate-power-save.bk72xx-ard.yaml @@ -0,0 +1,9 @@ +# A wifi power_save_mode other than NONE is forced off with a warning while +# bk72xx_ble is configured (esphome#18592); this config must still validate. +packages: + bk72xx_ble: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + power_save_mode: high From fe0f04b2e434cda731b732e88e59590a65b7f849 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 15 Sep 2026 09:29:03 -0700 Subject: [PATCH 185/759] [modbus] Add allow_broadcast_read and expect_broadcast_write_response options (#19304) --- esphome/components/modbus/__init__.py | 158 +++++++++-- esphome/components/modbus/modbus.cpp | 26 +- esphome/components/modbus/modbus.h | 37 ++- esphome/components/modbus_client/__init__.py | 56 ++-- .../components/modbus_client/modbus_client.h | 81 ++++-- .../components/modbus_controller/__init__.py | 66 ++++- .../modbus_controller/modbus_controller.cpp | 13 +- .../modbus_controller/modbus_controller.h | 25 +- .../modbus_controller/number/__init__.py | 8 +- .../modbus_controller/output/__init__.py | 9 +- .../modbus_controller/select/__init__.py | 8 +- .../modbus_controller/switch/__init__.py | 8 +- tests/component_tests/modbus/test_modbus.py | 3 +- .../modbus_client/test_modbus_client.py | 146 +++++++++- .../test_broadcast_address.py | 79 ++++++ .../modbus_controller/test_custom_pdu.py | 75 +++++- .../modbus/modbus_client_hub_test.cpp | 255 ++++++++++++++++++ tests/components/modbus_client/common.yaml | 4 +- .../validate-broadcast.esp32-idf.yaml | 36 +++ .../components/modbus_controller/common.yaml | 1 - .../validate-broadcast.esp32-idf.yaml | 29 ++ 21 files changed, 994 insertions(+), 129 deletions(-) create mode 100644 tests/component_tests/modbus_controller/test_broadcast_address.py create mode 100644 tests/components/modbus_client/validate-broadcast.esp32-idf.yaml create mode 100644 tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 76cfdbed706..0a34ed037d5 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import Any, Literal, NamedTuple @@ -48,6 +49,8 @@ ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True +CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" +CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" @@ -56,6 +59,28 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + +# Codes the hub refuses at address 0; keep in sync with modbus::helpers::is_function_code_broadcastable(). +_NON_BROADCASTABLE_FUNCTION_CODES = frozenset( + {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18} +) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code (the runtime hub never queues one: + queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def is_function_code_broadcastable(function_code: int) -> bool: + """True if the hub accepts the function code at address 0 without allow_broadcast_read.""" + return function_code & 0x7F not in _NON_BROADCASTABLE_FUNCTION_CODES + + class _CommandOption(NamedTuple): """One per-command option forwarded to the hub (modbus::CommandOptions).""" @@ -64,14 +89,47 @@ class _CommandOption(NamedTuple): validator: Any # the static (non-templatable) validator for the key cpp_type: Any # the C++ type the value is generated as default: Any + # Function codes the hub honours the option on; it is stripped from any other. + applies_to: Callable[[int], bool] + requires_broadcast_address: bool = False -# Per-direction command options. Single-sourcing the schema and the setter generation here keeps -# them from drifting; the C++ side must add the matching field per the rules documented on -# CommandOptions (modbus.h). +def _not_write(function_code: int) -> bool: + return not is_function_code_write(function_code) + + +def _not_broadcastable(function_code: int) -> bool: + return not is_function_code_broadcastable(function_code) + + +# Per-direction command options, single-sourced so the schema, setters and applicability rule cannot +# drift; the C++ side adds the matching field per the rules on CommandOptions (modbus.h). _COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { - "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], - "write": [], + "read": [ + _CommandOption( + CONF_CONTINUOUS, "continuous", cv.boolean, bool, False, _not_write + ), + _CommandOption( + CONF_ALLOW_BROADCAST_READ, + "allow_broadcast_read", + cv.boolean, + bool, + False, + _not_broadcastable, + requires_broadcast_address=True, + ), + ], + "write": [ + _CommandOption( + CONF_EXPECT_BROADCAST_WRITE_RESPONSE, + "expect_broadcast_write_response", + cv.boolean, + bool, + False, + is_function_code_broadcastable, + requires_broadcast_address=True, + ), + ], } @@ -82,32 +140,75 @@ def _command_options(direction: str) -> list[_CommandOption]: raise ValueError(f"unknown command-options direction {direction!r}") from None -# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 -# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. -_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) +def broadcast_only_option_keys() -> list[str]: + return [ + option.conf_key + for options in _COMMAND_OPTIONS.values() + for option in options + if option.requires_broadcast_address + ] -def is_function_code_write(function_code: int) -> bool: - """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, - so an exception-flagged code still classifies by its base code (the runtime hub never queues one: - queue_pdu() refuses them). Keep in sync with modbus::helpers::is_function_code_write().""" - return function_code & 0x7F in _WRITE_FUNCTION_CODES +def reject_broadcast_options_for_unicast( + address_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a broadcast-only option set true on a literal address other than 0.""" + + def validator(config: ConfigType) -> ConfigType: + address = config.get(address_key) + if not isinstance(address, int) or address == BROADCAST_ADDRESS: + return config + for key in broadcast_only_option_keys(): + if config.get(key) is True: + raise cv.Invalid( + f"'{key}' only applies to the broadcast address; set '{address_key}: 0' or " + f"remove the option.", + path=[key], + ) + return config + + return validator + + +def reject_inapplicable_command_options( + pdu_key: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject an option set true that the hub would strip from a literal PDU's function code.""" + + def validator(config: ConfigType) -> ConfigType: + pdu = config[pdu_key] + if not isinstance(pdu, list): + return config + for direction in _COMMAND_OPTIONS: + for option in _command_options(direction): + if config.get(option.conf_key) is True and not option.applies_to( + pdu[0] + ): + raise cv.Invalid( + f"'{option.conf_key}: true' does not apply to function code " + f"0x{pdu[0]:02X}", + path=[option.conf_key], + ) + return config + + return validator def command_options_schema( - *, direction: Literal["read", "write"], templatable: bool = False + *, + direction: Literal["read", "write"], + templatable: bool = False, + function_code: int | None = None, ) -> dict[cv.Optional, Any]: - """Schema fragment for the per-command options a component forwards to the hub - (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are - direction-specific so a schema never offers an option the hub would strip (e.g. - continuous on a write); the write side has no options yet. For actions (templatable=True the - keys also accept lambdas), register the values with register_templatable_command_options(). + """Schema fragment for the per-command options of one direction; `function_code` (a typed + action's fixed code) leaves out the options that do not apply to it. """ return { cv.Optional(option.conf_key, default=option.default): ( cv.templatable(option.validator) if templatable else option.validator ) for option in _command_options(direction) + if function_code is None or option.applies_to(function_code) } @@ -130,6 +231,25 @@ def command_options_expression( ) +def add_command_options( + var: MockObj, + setter: str, + config: ConfigType, + *, + direction: Literal["read", "write"], +) -> None: + """Emit `var.()` for a config validated with command_options_schema() of the + same direction, skipped when every option is at its C++ default.""" + if all( + config.get(option.conf_key, option.default) == option.default + for option in _command_options(direction) + ): + return + cg.add( + getattr(var, setter)(command_options_expression(config, direction=direction)) + ) + + async def register_templatable_command_options( var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str ) -> None: diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index f428236a821..037901a8733 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -832,7 +832,7 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); - if (cmd->frame.address() == BROADCAST_ADDRESS) { + if (cmd->fire_and_forget()) { // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above // reports the transmission, and the entry then retires with no terminal callback instead of // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already @@ -1074,11 +1074,6 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M return false; } - if (address == BROADCAST_ADDRESS && !helpers::is_function_code_broadcastable(pdu[0])) { - ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); - return false; - } - // Normalize the caller's options in place (the param is a by-value copy) so everything stored or // merged below carries effective options, never the raw request. // continuous is ignored for every mutating code (re-writing a value forever is never intended). @@ -1086,6 +1081,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGW(TAG, "continuous is ignored for a mutating function (0x%X, address %" PRIu8 ")", pdu[0], address); options.continuous = false; } + if (address != BROADCAST_ADDRESS) { + options.allow_broadcast_read = false; + options.expect_broadcast_write_response = false; + } else { + const bool broadcastable = helpers::is_function_code_broadcastable(pdu[0]); + if (options.allow_broadcast_read && broadcastable) { + ESP_LOGV(TAG, "allow_broadcast_read is ignored for function 0x%X: it is broadcastable", pdu[0]); + options.allow_broadcast_read = false; + } + if (options.expect_broadcast_write_response && !broadcastable) { + ESP_LOGV(TAG, "expect_broadcast_write_response is ignored for function 0x%X: it is not broadcastable", pdu[0]); + options.expect_broadcast_write_response = false; + } + if (!broadcastable && !options.allow_broadcast_read) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } + } // A duplicate of a live entry with the same owner is not queued twice; it resolves against that // entry: anonymous -> dropped; continuous incoming -> convert the entry to a poll; one-shot onto a @@ -1126,6 +1139,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGV(TAG, "Frame already active for %" PRIu8 ", request absorbed (pending %" PRIu8 ")", address, item.pending); } + item.options.expect_broadcast_write_response |= options.expect_broadcast_write_response; return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 7d7818239d5..1623c099a34 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -111,11 +111,15 @@ enum class FrameState : uint8_t { // Per-command send options. Append-only; pass via designated initializers ({.continuous = true}). // A new field reaches the queue with no plumbing but arrives inert until it defines three rules: // normalization in queue_pdu(), a merge rule for duplicate absorption, and teardown in -// retire()/silent_retire(). +// retire()/silent_retire(). Bit-packed: stored per entry, controller and writer entity, passed by value. struct CommandOptions { // A continuous poll lives in the queue until cancelled or failed; ignored for mutating codes. - bool continuous{false}; + bool continuous : 1 {false}; + // Wait for the reply to a read sent to address 0, for a device that answers the broadcast address. + bool allow_broadcast_read : 1 {false}; + bool expect_broadcast_write_response : 1 {false}; }; +static_assert(sizeof(CommandOptions) == 1, "CommandOptions must stay one byte"); struct ModbusDeviceCommand { ModbusClientDevice *device; @@ -158,6 +162,10 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + bool fire_and_forget() const { + return this->frame.address() == BROADCAST_ADDRESS && !this->options.allow_broadcast_read && + !this->options.expect_broadcast_write_response; + } // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with no terminal callback. void complete_broadcast() { @@ -191,7 +199,8 @@ struct ModbusDeviceCommand { } else if (!this->waiting_state()) { // an already-retired shell stays put; off the wire -> RETIRED this->state = FrameState::RETIRED; } - this->options = {}; // reset every option + // Only continuous ends with the clear; the delivery flags must survive for a granted retry. + this->options.continuous = false; } // True while the entry is still waiting for a response @@ -534,27 +543,27 @@ class ModbusClientDevice { return this->queue_pdu( helpers::create_read_pdu(FunctionCode::READ_DISCRETE_INPUTS, start_address, number_of_inputs), options); } - bool write_single_register(uint16_t start_address, uint16_t value) { - return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value)); + bool write_single_register(uint16_t start_address, uint16_t value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_register_pdu(start_address, value), options); } - bool write_single_coil(uint16_t address, bool value) { - return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value)); + bool write_single_coil(uint16_t address, bool value, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_single_coil_pdu(address, value), options); } - bool write_multiple_registers(uint16_t start_address, std::span values) { + bool write_multiple_registers(uint16_t start_address, std::span values, CommandOptions options = {}) { // Empty goes to the full-size builder so the rejection log names this method's limit, not the small one's. if (!values.empty() && values.size() <= helpers::MAX_FEW_REGISTERS) - return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values)); - return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values)); + return this->queue_pdu(helpers::create_write_few_registers_pdu(start_address, values), options); + return this->queue_pdu(helpers::create_write_registers_pdu(start_address, values), options); } /// Note: std::vector cannot bind to std::span; use a contiguous bool container or the packed /// overload. - bool write_multiple_coils(uint16_t start_address, std::span values) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values)); + bool write_multiple_coils(uint16_t start_address, std::span values, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, values), options); } /// Packed variant: a PackedBits view (the same layout on_read_coils() delivers), so /// read-modify-write needs no unpack/repack. - bool write_multiple_coils(uint16_t start_address, PackedBits bits) { - return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); + bool write_multiple_coils(uint16_t start_address, PackedBits bits, CommandOptions options = {}) { + return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits), options); } /// FC 0x17: the read-back is delivered through on_read_holding_registers(), and a device exception /// (typically a rejected write half) arrives there too via its status - one callback handles both diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index a59eb910664..66ddcd7722d 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -7,7 +7,6 @@ from esphome.components import modbus import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_CONTINUOUS, CONF_COUNT, CONF_ID, CONF_ON_ERROR, @@ -158,24 +157,6 @@ _ACTION_BASE_SCHEMA = cv.Schema( ) -def _no_continuous_on_write(config: ConfigType) -> ConfigType: - """Reject `continuous: true` on a static write PDU: continuous polling only applies to reads. - Only the fully-static case is decidable here; the hub strips the flag from mutating PDUs at - runtime, so a templated pdu or continuous falls through to that backstop.""" - pdu = config[CONF_PDU] - if ( - isinstance(pdu, list) - and config.get(CONF_CONTINUOUS) is True - and modbus.is_function_code_write(pdu[0]) - ): - raise cv.Invalid( - f"'{CONF_CONTINUOUS}: true' does not apply to a write PDU (function code " - f"0x{pdu[0]:02X}); continuous polling only applies to reads", - path=[CONF_CONTINUOUS], - ) - return config - - MODBUS_CLIENT_SEND_SCHEMA = cv.All( _ACTION_BASE_SCHEMA.extend( { @@ -186,10 +167,12 @@ MODBUS_CLIENT_SEND_SCHEMA = cv.All( ) ), **modbus.command_options_schema(direction="read", templatable=True), + **modbus.command_options_schema(direction="write", templatable=True), cv.Optional(CONF_ON_RESPONSE): _handler_schema(), } ), - _no_continuous_on_write, + modbus.reject_inapplicable_command_options(CONF_PDU), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -261,8 +244,7 @@ async def register_client_action( var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf ) # Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the - # matching direction so a write action never generates a read option's setter; the write side - # has no options yet, so this is a no-op there. + # matching direction so a write action never generates a read option's setter. await modbus.register_templatable_command_options( var, config, args, command_direction ) @@ -279,6 +261,8 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) template_ = await cg.templatable(config[CONF_PDU], args, _PDU_BUFFER) cg.add(var.set_pdu(template_)) + # The read set is wired by register_client_action() below. + await modbus.register_templatable_command_options(var, config, args, "write") return await register_client_action( var, config, @@ -353,6 +337,7 @@ def _read_schema(max_count: int) -> cv.All: } ), _no_address_overflow(CONF_COUNT), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -364,21 +349,35 @@ def _write_multiple_schema(item: Callable[[Any], Any], max_values: int) -> cv.Al cv.Required(CONF_VALUES): cv.templatable( cv.All(cv.ensure_list(item), cv.Length(min=1, max=max_values)) ), + **modbus.command_options_schema(direction="write", templatable=True), } ), _no_address_overflow(CONF_VALUES), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) _READ_REGISTERS_SCHEMA = _read_schema(modbus.MAX_NUM_OF_REGISTERS_TO_READ) -_WRITE_SINGLE_REGISTER_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t)} +_WRITE_SINGLE_REGISTER_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.hex_uint16_t), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) # A coil is one bit, so the value is a boolean - the wire only carries 0x0000 or 0xFF00. -_WRITE_SINGLE_COIL_SCHEMA = _TYPED_ACTION_SCHEMA.extend( - {cv.Required(CONF_VALUE): cv.templatable(cv.boolean)} +_WRITE_SINGLE_COIL_SCHEMA = cv.All( + _TYPED_ACTION_SCHEMA.extend( + { + cv.Required(CONF_VALUE): cv.templatable(cv.boolean), + **modbus.command_options_schema(direction="write", templatable=True), + } + ), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) @@ -542,10 +541,15 @@ _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), ) ), + # 0x17 counts as a read at address 0, so it takes allow_broadcast_read only. + **modbus.command_options_schema( + direction="read", templatable=True, function_code=0x17 + ), } ), _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), + modbus.reject_broadcast_options_for_unicast(CONF_ADDRESS), ) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index 03744239a9b..4c1d11da838 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -85,18 +85,36 @@ template class ClientActionBase : public Action, public m /// builds its static struct; declaring the values here instead of per action means a new read option /// costs one TEMPLATABLE_VALUE plus one field below, and every read action picks it up. /// The read/write split mirrors _COMMAND_OPTIONS in the modbus component's Python -/// (command_options_schema(direction="read") adds exactly these keys). When a write-side option -/// arrives it gets a WriteCommandOptions twin, so write actions never carry read-only members. +/// (command_options_schema(direction="read") adds exactly these keys); WriteCommandOptions is the twin. template class ReadCommandOptions { public: // Poll: re-queue after each success until downgraded (replay with false) or failed. The hub strips // it for mutating function codes at the door (see modbus::CommandOptions). TEMPLATABLE_VALUE(bool, continuous) + TEMPLATABLE_VALUE(bool, allow_broadcast_read) protected: /// The options for this send, with every templatable value resolved against the action's arguments. modbus::CommandOptions command_options_(const Ts &...x) const { - return {.continuous = this->continuous_.value(x...)}; + return {.continuous = this->continuous_.value(x...), + .allow_broadcast_read = this->allow_broadcast_read_.value(x...)}; + } +}; + +/// The write-side per-command options (command_options_schema(direction="write") adds exactly these keys). +template class WriteCommandOptions { + public: + TEMPLATABLE_VALUE(bool, expect_broadcast_write_response) + + protected: + /// Resolves every write option into `options`, so send's merge of both sets stays exhaustive. + void apply_write_command_options_(modbus::CommandOptions &options, const Ts &...x) const { + options.expect_broadcast_write_response = this->expect_broadcast_write_response_.value(x...); + } + modbus::CommandOptions write_command_options_(const Ts &...x) const { + modbus::CommandOptions options{}; + this->apply_write_command_options_(options, x...); + return options; } }; @@ -107,8 +125,11 @@ template class ReadCommandOptions { /// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert). /// A PduBuffer drops bytes past modbus::MAX_PDU_SIZE without reporting it (the hub's oversize check /// cannot fire - that limit is the capacity), so an over-long lambda-built PDU is silently truncated. +/// A raw PDU may be a read or a write, so this action carries both option sets. template -class ModbusClientSendAction : public ClientActionBase, public ReadCommandOptions { +class ModbusClientSendAction : public ClientActionBase, + public ReadCommandOptions, + public WriteCommandOptions { public: TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu) @@ -116,7 +137,11 @@ class ModbusClientSendAction : public ClientActionBase, public ReadComman return &this->response_trigger_; } - void play(const Ts &...x) override { this->send_or_resolve_(this->pdu_.value(x...), this->command_options_(x...)); } + void play(const Ts &...x) override { + modbus::CommandOptions options = this->command_options_(x...); + this->apply_write_command_options_(options, x...); + this->send_or_resolve_(this->pdu_.value(x...), options); + } void on_response(std::span request_pdu, std::span response_pdu) override { this->response_trigger_.trigger(request_pdu, response_pdu); @@ -218,7 +243,8 @@ template class ReadBitsAction : public TypedClientActionBase class WriteSingleRegisterAction : public TypedClientActionBase { +template +class WriteSingleRegisterAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(uint16_t, value) @@ -227,7 +253,8 @@ template class WriteSingleRegisterAction : public TypedClientAct void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_register_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_register(uint16_t address, uint16_t value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -240,7 +267,8 @@ template class WriteSingleRegisterAction : public TypedClientAct /// modbus_client.write_single_coil: on_response is the acknowledgement (no arguments). A coil holds one /// bit, so the value is a bool - the wire only ever carries 0x0000 or 0xFF00. -template class WriteSingleCoilAction : public TypedClientActionBase { +template +class WriteSingleCoilAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) TEMPLATABLE_VALUE(bool, value) @@ -249,7 +277,8 @@ template class WriteSingleCoilAction : public TypedClientActionB void play(const Ts &...x) override { this->send_or_resolve_( - modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...))); + modbus::helpers::create_write_single_coil_pdu(this->start_address_.value(x...), this->value_.value(x...)), + this->write_command_options_(x...)); } void on_write_single_coil(uint16_t address, bool value, modbus::ResponseStatus status) override { if (modbus::succeeded(status)) @@ -264,7 +293,8 @@ template class WriteSingleCoilAction : public TypedClientActionB /// A `values:` list is emitted as a flash array and sent straight from there; only a lambda builds a /// vector, and only when it runs. Same split as canbus's send action, and for the same reason: a static /// list must not allocate on every play(). -template class WriteMultipleRegistersAction : public TypedClientActionBase { +template +class WriteMultipleRegistersAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -288,11 +318,13 @@ template class WriteMultipleRegistersAction : public TypedClient // the empty PDU then resolves via on_not_sent like any refused send. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_write_registers_pdu( - start, std::span(this->values_.data, static_cast(this->len_)))); + start, std::span(this->values_.data, static_cast(this->len_))), + this->write_command_options_(x...)); return; } const std::vector values = this->values_.func(x...); - this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values))); + this->send_or_resolve_(modbus::helpers::create_write_registers_pdu(start, std::span(values)), + this->write_command_options_(x...)); } void on_write_multiple_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override { @@ -313,7 +345,8 @@ template class WriteMultipleRegistersAction : public TypedClient /// A `values:` list is packed into wire layout at code-generation time and stored in flash, so play() /// neither allocates nor packs. A lambda returns std::vector - already a bit per coil rather than /// a byte - and is packed into a stack buffer on the way to the builder. -template class WriteMultipleCoilsAction : public TypedClientActionBase { +template +class WriteMultipleCoilsAction : public TypedClientActionBase, public WriteCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, start_address) @@ -334,13 +367,16 @@ template class WriteMultipleCoilsAction : public TypedClientActi const uint16_t start = this->start_address_.value(x...); if (this->count_ >= 0) { const auto count = static_cast(this->count_); - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu( - start, - modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), count))); + this->send_or_resolve_( + modbus::helpers::create_write_coils_pdu( + start, modbus::PackedBits(std::span(this->values_.packed, modbus::packed_bit_bytes(count)), + count)), + this->write_command_options_(x...)); return; } // The builder packs and bound-checks; an over-long set is rejected and logged there. - this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...))); + this->send_or_resolve_(modbus::helpers::create_write_coils_pdu(start, this->values_.func(x...)), + this->write_command_options_(x...)); } void on_write_multiple_coils(uint16_t start_address, modbus::PackedBits bits, modbus::ResponseStatus status) override { @@ -359,7 +395,8 @@ template class WriteMultipleCoilsAction : public TypedClientActi /// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in /// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. -template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { +template +class ReadWriteMultipleRegistersAction : public TypedClientActionBase, public ReadCommandOptions { public: TEMPLATABLE_VALUE(uint16_t, read_address) TEMPLATABLE_VALUE(uint16_t, read_count) @@ -385,13 +422,15 @@ template class ReadWriteMultipleRegistersAction : public TypedCl // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. if (this->len_ >= 0) { this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, - std::span(this->values_.data, static_cast(this->len_)))); + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_))), + this->command_options_(x...)); return; } const std::vector values = this->values_.func(x...); this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( - read_start, read_count, write_start, std::span(values))); + read_start, read_count, write_start, std::span(values)), + this->command_options_(x...)); } // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index f888cc060e3..aa72a08a60b 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -103,12 +103,20 @@ def _warn_removed_options(config: ConfigType) -> ConfigType: def _reject_broadcast_address(config: ConfigType) -> ConfigType: - """A modbus_controller polls one device, so its address cannot be the broadcast address (0): - a broadcast is never answered (Modbus 4.1), so no register could ever read back.""" + """Address 0 is rejected unless allow_broadcast_read, which in turn requires address 0.""" + if config[modbus.CONF_ALLOW_BROADCAST_READ]: + if config.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_ALLOW_BROADCAST_READ}' only applies to the broadcast address; " + f"set 'address: 0' or remove the option.", + [modbus.CONF_ALLOW_BROADCAST_READ], + ) + return config modbus.reject_broadcast_address( config.get(CONF_ADDRESS), "a modbus_controller device address", - "Assign the unit address of the device you want to poll.", + "Assign the unit address of the device you want to poll, or set allow_broadcast_read if " + "it answers address 0.", [CONF_ADDRESS], ) return config @@ -346,12 +354,52 @@ def _reject_continuous_write_custom_pdu(config: ConfigType) -> None: ) +def _reject_broadcastable_custom_pdu(config: ConfigType) -> None: + """A broadcastable custom_pdu under an address-0 controller is a real broadcast, never answered.""" + pdu = config.get(CONF_CUSTOM_PDU) + if pdu is None or not modbus.is_function_code_broadcastable(pdu[0]): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if ( + controller.get(CONF_ADDRESS) == modbus.BROADCAST_ADDRESS + and controller.get(modbus.CONF_ALLOW_BROADCAST_READ) is True + ): + raise cv.Invalid( + f"a '{CONF_CUSTOM_PDU}' with function code 0x{pdu[0] & 0x7F:02X} is a real broadcast at " + f"address 0 and is never answered, so it can't be polled through the " + f"'{controller[CONF_ID]}' modbus_controller; use a read function code.", + [CONF_CUSTOM_PDU], + ) + + def validate_custom_pdu_item(config: ConfigType) -> None: - """Final-validate for the read platforms that accept custom_pdu (sensor, binary_sensor, - text_sensor): migrate the deprecated custom_command, then reject a write-coded custom_pdu under a - continuously-polling controller.""" + """Final-validate for the platforms that accept custom_pdu.""" migrate_custom_command(config) _reject_continuous_write_custom_pdu(config) + _reject_broadcastable_custom_pdu(config) + + +def _reject_write_option_off_broadcast(config: ConfigType) -> None: + if not any(config.get(key) is True for key in modbus.broadcast_only_option_keys()): + return + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_MODBUS_CONTROLLER_ID])[:-1] + controller = fconf.get_config_for_path(path) + if controller.get(CONF_ADDRESS) != modbus.BROADCAST_ADDRESS: + raise cv.Invalid( + f"'{modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE}' only applies when the " + f"'{controller[CONF_ID]}' modbus_controller is at address 0; remove the option.", + [modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], + ) + + +def validate_writer_item(config: ConfigType) -> None: + """Final-validate for the writer platforms (number, output, select, switch).""" + if CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config: + validate_custom_pdu_item(config) + _reject_write_option_off_broadcast(config) def _final_validate(config: ConfigType) -> None: @@ -448,11 +496,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - cg.add( - var.set_read_options( - modbus.command_options_expression(config, direction="read") - ) - ) + modbus.add_command_options(var, "set_read_options", config, direction="read") await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index c7fc10a0bb0..b8d06d3d5a3 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -24,7 +24,7 @@ void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint1 bool WriterDevice::send_raw_frame_deprecated(std::span frame) { if (frame.empty()) return false; - return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this, this->write_options_); } void ControllerDevice::set_controller(ModbusController *controller) { @@ -234,10 +234,13 @@ void ModbusCommandItem::on_sent(std::span request_pdu) { // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) // A custom polling command sends its PDU to this controller's own address, so only a factory custom // command (a raw frame staged in payload) can carry a different address byte. + // An address-0 read with allow_broadcast_read is answered, so it keeps its terminal callback. uint8_t wire_address = this->address_; if (this->function_code_ == FunctionCode::CUSTOM && !this->payload.empty()) wire_address = this->payload.data()[0]; - if (wire_address == modbus::BROADCAST_ADDRESS) + const bool answered = this->controller_->read_options().allow_broadcast_read && + !modbus::helpers::is_function_code_broadcastable(request_pdu[0]); + if (wire_address == modbus::BROADCAST_ADDRESS && !answered) this->controller_->unqueue_command(this); } @@ -285,8 +288,8 @@ void ModbusController::queue_command(ModbusCommandItem command) { this->one_shot_command_items_.push_back(make_unique(std::move(command))); // A refused frame gets no terminal callback (see the hub contract), so reclaim the item here. auto &item = this->one_shot_command_items_.back(); - // We intentionally do not pass read_options_ here, because one-shot commands are usually writes, and are non-polling. - if (!item->send()) { + // One-shots never poll, so only the broadcast flag is passed (the hub strips it from writes). + if (!item->send({.allow_broadcast_read = this->read_options_.allow_broadcast_read})) { // The caller (e.g. a write entity) has usually already published optimistically - surface the loss. ESP_LOGW(TAG, "Command refused by hub: type=0x%X address=0x%X", static_cast(item->register_type()), item->register_address()); @@ -340,7 +343,7 @@ void ModbusController::update() { if (this->can_send()) { for (auto &poll : this->polling_devices_) { ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address()); - // read_options_ carries the controller's continuous flag (the offline probe above sends it too). + // read_options_ carries the controller's read-side flags (the offline probe above sends them too). // A refusal is already logged by the hub; note the affected range for controller-level diagnostics. if (!poll.queue(this->read_options_)) { ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address()); diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 821c500a31e..741d4f6f00d 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -280,10 +280,11 @@ class ControllerDevice : protected modbus::ModbusClientDevice { void notify_online_(std::span request_pdu); - /// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's - /// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0. - bool dispatched_{false}; - bool write_buffer_deprecated_warned_{false}; + /// Write-path state for WriterEntity's forwarders, packed into the base's tail padding. The warn flag + /// leaves in 2027.3.0. + bool dispatched_ : 1 {false}; + bool write_buffer_deprecated_warned_ : 1 {false}; + modbus::CommandOptions write_options_{}; ModbusController *controller_{nullptr}; }; @@ -305,6 +306,8 @@ class WriterDevice final : public ControllerDevice { bool dispatched() const { return this->dispatched_; } void set_dispatched() { this->dispatched_ = true; } void clear_dispatched() { this->dispatched_ = false; } + modbus::CommandOptions write_options() const { return this->write_options_; } + void set_write_options(modbus::CommandOptions options) { this->write_options_ = options; } /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); @@ -326,27 +329,29 @@ class WriterEntity { /// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records /// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write. bool dispatched() const { return this->device_.dispatched(); } + void set_write_options(modbus::CommandOptions options) { this->device_.set_write_options(options); } bool write_single_register(uint16_t address, uint16_t value) { this->device_.set_dispatched(); - return this->device_.write_single_register(address, value); + return this->device_.write_single_register(address, value, this->device_.write_options()); } bool write_single_coil(uint16_t address, bool value) { this->device_.set_dispatched(); - return this->device_.write_single_coil(address, value); + return this->device_.write_single_coil(address, value, this->device_.write_options()); } bool write_multiple_registers(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_registers(address, values); + return this->device_.write_multiple_registers(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, std::span values) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, values); + return this->device_.write_multiple_coils(address, values, this->device_.write_options()); } bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { this->device_.set_dispatched(); - return this->device_.write_multiple_coils(address, bits); + return this->device_.write_multiple_coils(address, bits, this->device_.write_options()); } - bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + bool queue_pdu(std::span pdu) { return this->queue_pdu(pdu, this->device_.write_options()); } + bool queue_pdu(std::span pdu, modbus::CommandOptions options) { this->device_.set_dispatched(); return this->device_.queue_pdu(pdu, options); } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index 6f7bf588af7..242e2eea218 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import number +from esphome.components import modbus, number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, @@ -23,8 +23,8 @@ from .. import ( add_modbus_base_properties, modbus_calc_properties, modbus_controller_ns, - validate_custom_pdu_item, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -84,6 +84,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STEP, default=1): cv.positive_float, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), validate_min_max, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -122,6 +123,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) await add_modbus_base_properties(var, config, ModbusNumber) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 0e8d5363d74..c964ced987b 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import output +from esphome.components import modbus, output from esphome.components.modbus.helpers import ( SENSOR_VALUE_TYPE, PduBuffer, @@ -18,6 +18,7 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, + validate_writer_item, ) from ..const import ( CONF_CUSTOM_COMMAND, @@ -79,6 +80,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), "holding": cv.All( @@ -98,6 +100,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_, cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), } ), reject_odd_holding_write_offset, @@ -111,6 +114,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: byte_offset = modbus_calc_properties(config) # Binary Output @@ -153,6 +159,7 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) parent = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_parent(parent)) if write_template: cg.add(var.set_write_template(write_template)) diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index d8319932ab6..6fc8c8331cf 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import Any import esphome.codegen as cg -from esphome.components import select +from esphome.components import modbus, select from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, RegisterValues import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC @@ -15,6 +15,7 @@ from .. import ( modbus_controller_ns, validate_range_reuse_migration, validate_skip_updates_deprecated, + validate_writer_item, ) from ..const import ( CONF_FORCE_NEW_RANGE, @@ -77,6 +78,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_REGISTER_COUNT): cv.positive_int, cv.Required(CONF_OPTIONSMAP): ensure_option_map(), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -86,6 +88,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = validate_writer_item + + async def to_code(config: ConfigType) -> None: options_map = config[CONF_OPTIONSMAP] @@ -104,6 +109,7 @@ async def to_code(config: ConfigType) -> None: cg.add(parent.add_sensor_item(var)) cg.add(var.set_parent(parent)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if CONF_LAMBDA in config: diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 00b67446a31..2c5b92b810b 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import switch +from esphome.components import modbus, switch from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID @@ -13,9 +13,9 @@ from .. import ( modbus_calc_properties, modbus_controller_ns, reject_odd_holding_write_offset, - validate_custom_pdu_item, validate_modbus_register, validate_range_reuse_migration, + validate_writer_item, ) from ..const import ( CONF_BITMASK, @@ -51,6 +51,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, + **modbus.command_options_schema(direction="write"), cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, } ), @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.All( validate_range_reuse_migration, ) -FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item +FINAL_VALIDATE_SCHEMA = validate_writer_item async def to_code(config: ConfigType) -> None: @@ -78,6 +79,7 @@ async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) + modbus.add_command_options(var, "set_write_options", config, direction="write") assumed_state = config[CONF_ASSUMED_STATE] cg.add(var.set_assumed_state(assumed_state)) if not assumed_state: diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 0e53c55b50b..1eafb131664 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None: def test_client_schema_still_accepts_address_zero() -> None: - # Not rejected for clients today, but not supported either: a client broadcast gets no reply and - # stalls the hub for the full send-wait. + # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 diff --git a/tests/component_tests/modbus_client/test_modbus_client.py b/tests/component_tests/modbus_client/test_modbus_client.py index cab944d825e..fcccae144e0 100644 --- a/tests/component_tests/modbus_client/test_modbus_client.py +++ b/tests/component_tests/modbus_client/test_modbus_client.py @@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot. import pytest from esphome import config_validation as cv -from esphome.components import modbus_client +from esphome.components import modbus, modbus_client from esphome.components.modbus_client import ( CONF_ON_NO_RESPONSE, CONF_ON_NOT_SENT, @@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None: def test_continuous_on_write_pdu_rejected() -> None: """A literal write-code PDU with continuous: true is rejected at config time (reads only).""" - with pytest.raises(cv.Invalid, match="does not apply to a write PDU"): + with pytest.raises(cv.Invalid, match="does not apply to function code"): MODBUS_CLIENT_SEND_SCHEMA( { CONF_ADDRESS: 0x01, @@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None: """ assert modbus_client.MULTI_CONF is True assert modbus_client.MULTI_CONF_NO_DEFAULT is True + + +@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ]) +def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None: + # A read option set true on a static write PDU is refused at validation, naming the key. + config = { + CONF_ADDRESS: 1, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + key: True, + } + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA(config) + + +def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None: + # allow_broadcast_read defaults to False and is accepted on a read PDU to address 0. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]} + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_send_rejects_write_option_on_static_read_pdu() -> None: + # The write-side option is refused on a static read PDU, the mirror of the read-option check. + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + with pytest.raises( + cv.Invalid, match=f"'{key}: true' does not apply to function code" + ): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True} + ) + + +def test_send_accepts_write_option_on_static_write_pdu() -> None: + config = MODBUS_CLIENT_SEND_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01], + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True + + +def test_write_actions_offer_write_option_only() -> None: + # Every write action takes expect_broadcast_write_response and none of the read options. + from esphome.components.modbus_client import ( + _WRITE_MULTIPLE_COILS_SCHEMA, + _WRITE_MULTIPLE_REGISTERS_SCHEMA, + _WRITE_SINGLE_COIL_SCHEMA, + _WRITE_SINGLE_REGISTER_SCHEMA, + CONF_START_ADDRESS, + CONF_VALUE, + CONF_VALUES, + ) + + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True} + for schema, extra in ( + (_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}), + (_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}), + (_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}), + (_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}), + ): + config = schema({**base, **extra}) + assert config[write_key] is True + assert modbus.CONF_ALLOW_BROADCAST_READ not in config + with pytest.raises(cv.Invalid): + schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_send_options_follow_the_hub_classification() -> None: + # A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one; + # 0x17 is a read for broadcast purposes, so the reverse holds. + write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + read_key = modbus.CONF_ALLOW_BROADCAST_READ + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True} + )[write_key] + with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True} + ) + pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01] + assert MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True} + )[read_key] + with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"): + MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True} + ) + + +def test_read_write_multiple_offers_allow_broadcast_read_only() -> None: + from esphome.components.modbus_client import ( + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + CONF_READ_ADDRESS, + CONF_VALUES, + CONF_WRITE_ADDRESS, + ) + + config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA( + { + CONF_ADDRESS: 0, + CONF_READ_ADDRESS: 0x10, + CONF_WRITE_ADDRESS: 0x20, + CONF_VALUES: [1], + modbus.CONF_ALLOW_BROADCAST_READ: True, + } + ) + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + assert CONF_CONTINUOUS not in config + assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config + + +@pytest.mark.parametrize( + "key", + [modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE], +) +def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None: + # A broadcast-only option on a literal non-zero address would be silently dropped by the hub. + if key == modbus.CONF_ALLOW_BROADCAST_READ: + pdu = [0x03, 0x00, 0x10, 0x00, 0x01] + else: + pdu = [0x06, 0x00, 0x10, 0x00, 0x01] + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True}) + # A templated address is not decidable at validation and passes through. + config = MODBUS_CLIENT_SEND_SCHEMA( + {CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True} + ) + assert config[key] is True diff --git a/tests/component_tests/modbus_controller/test_broadcast_address.py b/tests/component_tests/modbus_controller/test_broadcast_address.py new file mode 100644 index 00000000000..01bdacbf863 --- /dev/null +++ b/tests/component_tests/modbus_controller/test_broadcast_address.py @@ -0,0 +1,79 @@ +"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the +device answers it.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.modbus_controller import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _controller(address: int, **extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra}) + + +def test_address_zero_rejected_by_default() -> None: + with pytest.raises(cv.Invalid, match="broadcast address"): + _controller(0) + + +def test_address_zero_accepted_with_allow_broadcast_read() -> None: + config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + assert config[CONF_ADDRESS] == 0 + assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True + + +def test_allow_broadcast_read_defaults_false() -> None: + assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False + + +def test_writer_entity_takes_expect_broadcast_write_response() -> None: + # The write-side option lives on the writing platforms, not the controller. + from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID + from esphome.components.modbus_controller.switch import ( + CONFIG_SCHEMA as SWITCH_SCHEMA, + ) + from esphome.const import CONF_NAME + + key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE + base = { + CONF_MODBUS_CONTROLLER_ID: "ctl", + CONF_NAME: "Switch", + "register_type": "coil", + CONF_ADDRESS: 0x20, + } + assert SWITCH_SCHEMA(base)[key] is False + assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True}) + + +def test_allow_broadcast_read_requires_address_zero() -> None: + # The option only means something at address 0; elsewhere it would be silently inert. + with pytest.raises(cv.Invalid, match="only applies to the broadcast address"): + _controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True}) + + +def test_add_command_options_skips_defaults() -> None: + # The setter is only emitted when an option differs from its C++ default. + import esphome.codegen as cg + from esphome.const import CONF_CONTINUOUS + + var = cg.MockObj("ctl") + emitted: list = [] + original = cg.add + cg.add = emitted.append + try: + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read" + ) + assert emitted == [] + modbus.add_command_options( + var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read" + ) + assert len(emitted) == 1 + assert "set_read_options" in str(emitted[0]) + finally: + cg.add = original diff --git a/tests/component_tests/modbus_controller/test_custom_pdu.py b/tests/component_tests/modbus_controller/test_custom_pdu.py index a3a18da07f4..592f6c12bad 100644 --- a/tests/component_tests/modbus_controller/test_custom_pdu.py +++ b/tests/component_tests/modbus_controller/test_custom_pdu.py @@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there. import pytest from voluptuous import Invalid, MultipleInvalid +from esphome.components import modbus from esphome.components.modbus_controller import ( ModbusItemBaseSchema, validate_custom_pdu_item, @@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None: ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]}) -def _controller_full_config(*, continuous: bool) -> Config: +def _controller_full_config( + *, continuous: bool, allow_broadcast_read: bool = False +) -> Config: """A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the - final-validate to resolve the controller (and its continuous flag) from an item's + final-validate to resolve the controller (and its option flags) from an item's modbus_controller_id.""" ctl_id = ID("ctl", is_declaration=True) config = Config() config["modbus_controller"] = [ - {CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous} + { + CONF_ID: ctl_id, + CONF_ADDRESS: 0 if allow_broadcast_read else 1, + CONF_CONTINUOUS: continuous, + modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read, + } ] config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID])) return config @@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None: CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], } ) + + +def test_broadcastable_custom_pdu_rejected_under_broadcast_controller( + reset_full_config, +) -> None: + """A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast, + never answered, so it is rejected at final validate.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + with pytest.raises(Invalid, match="is a real broadcast at address 0"): + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x41, 0x00, 0x03], + } + ) + + +def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None: + """A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine.""" + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_custom_pdu_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01], + } + ) + + +def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None: + """expect_broadcast_write_response on a writer entity whose controller is not at address 0 is + rejected at final validate, where the controller's address is known.""" + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set(_controller_full_config(continuous=False)) + with pytest.raises( + Invalid, match="only applies when the 'ctl' modbus_controller is at address 0" + ): + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) + + +def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None: + from esphome.components.modbus_controller import validate_writer_item + + fv.full_config.set( + _controller_full_config(continuous=False, allow_broadcast_read=True) + ) + validate_writer_item( + { + CONF_MODBUS_CONTROLLER_ID: ID("ctl"), + modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True, + } + ) diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 18c04f32d5b..3bdfa094e0e 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { EXPECT_EQ(device.sent_count_, 0); // never transmitted } +// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent, +// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected + EXPECT_EQ(hub.entries(), 1u); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, reply); + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(reply)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from +// another unit id is an unexpected frame and interrupts the transaction as it would for any address. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(0x07, reply); + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a +// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + RetryingDevice device(&hub, BROADCAST_ADDRESS, true); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.clear_tx_queue_for_address(BROADCAST_ADDRESS); + EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED); + EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read); + + hub.timeout_waiting(); // retry granted: the entry is READY again + ASSERT_EQ(hub.queued_frames(), 1u); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply + EXPECT_EQ(hub.entries(), 1u); +} + +// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02}; + hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code + EXPECT_EQ(device.response_count_, 0); + EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED); +} + +// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered. +TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast, +// still fire-and-forget) and from a unicast frame (nothing to allow). +TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_EQ(broadcast_device.sent_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true})); + EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + +// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead +// of retiring at transmission, and the reply (from address 0) completes it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_EQ(device.sent_count_, 1); + EXPECT_TRUE(hub.waiting()); + EXPECT_EQ(hub.entries(), 1u); + + hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0 + EXPECT_EQ(device.response_count_, 1); + EXPECT_EQ(device.last_response_size_, sizeof(write)); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a +// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in +// flight rather than absorbed, so the queued entry's delivery mode is never changed under it. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 1u); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched + + hub.send_next_for_test(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and +// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the +// send, so the absorbed request still gets its terminal callback. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; + ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true})); + EXPECT_TRUE(hub.queued(0).fire_and_forget()); + ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed + EXPECT_EQ(hub.entries(), 1u); + EXPECT_FALSE(hub.queued(0).options.continuous); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); + + hub.send_next_for_test(); + EXPECT_TRUE(hub.waiting()); + hub.receive_frame_for_test(BROADCAST_ADDRESS, custom); + EXPECT_EQ(device.response_count_, 1); +} + +// A silent device leaves an expected write response to the normal send-wait timeout. +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true})); + hub.send_next_for_test(); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(device.response_count_, 0); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so +// the broadcast guard still refuses it) and from a unicast frame (nothing to expect). +TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS); + BroadcastProbeDevice unicast_device(&hub, 0x01); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; + EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true})); + EXPECT_EQ(hub.entries(), 0u); + + ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true})); + EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response); + EXPECT_FALSE(hub.queued(0).fire_and_forget()); +} + // The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the // hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index 76f7479a5cf..ce2965e449d 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -79,7 +79,8 @@ button: name: "Typed Actions" on_press: - modbus_client.write_single_register: - address: 0x01 + address: !lambda "return 1;" + expect_broadcast_write_response: true start_address: 0x0102 value: !lambda "return 42;" on_response: @@ -93,6 +94,7 @@ button: start_address: 0x10 count: 2 continuous: true + allow_broadcast_read: !lambda "return false;" on_response: then: - lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..d6a29d7175b --- /dev/null +++ b/tests/components/modbus_client/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,36 @@ +# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that +# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +button: + - platform: template + name: Broadcast probe + on_press: + - modbus_client.read_holding_registers: + address: 0 + allow_broadcast_read: true + start_address: 0x10 + count: 1 + on_response: + then: + - lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);' + - modbus_client.write_single_register: + address: 0 + expect_broadcast_write_response: true + start_address: 0x0102 + value: 42 + on_response: + then: + - logger.log: "broadcast write acked" + - modbus_client.read_write_multiple_registers: + address: 0 + allow_broadcast_read: true + read_address: 0x10 + read_count: 1 + write_address: 0x20 + values: [1] + - modbus_client.send: + address: 0 + expect_broadcast_write_response: true + pdu: [0x41, 0x01] diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index b9a7610cb73..b488e51f3c8 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -6,7 +6,6 @@ modbus_controller: on_online: then: logger.log: "Module Online" - binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml new file mode 100644 index 00000000000..49e89eaa20f --- /dev/null +++ b/tests/components/modbus_controller/validate-broadcast.esp32-idf.yaml @@ -0,0 +1,29 @@ +# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a +# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do +# not inflate the memory-impact baseline. +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +modbus_controller: + - id: modbus_controller_broadcast + address: 0 + allow_broadcast_read: true + modbus_id: modbus_bus + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_sensor + name: Broadcast Read Sensor + register_type: holding + address: 0x0010 + value_type: U_WORD + +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_broadcast + id: modbus_broadcast_switch + name: Broadcast Write Switch + register_type: coil + address: 0x20 + expect_broadcast_write_response: true From 0f500628dd001e0e4c0ec01921c2113925cad178 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 15 Sep 2026 17:33:33 +0100 Subject: [PATCH 186/759] [file] Keep resolved image paths as Path so config-hash normalizes them (#19267) Co-authored-by: J. Nick Koston --- esphome/components/file/image.py | 22 +++++----- .../unit_tests/components/file/test_image.py | 43 ++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 7cef7c754a4..ab769954124 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -42,7 +42,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import CORE, HexInt +from esphome.core import HexInt from esphome.cpp_generator import MockObj, MockObjClass from esphome.external_files import RemoteFile from esphome.types import ConfigType @@ -76,16 +76,18 @@ def compute_local_image_path(value: str | ConfigType) -> Path: return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value: str | ConfigType) -> str: - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) +def local_path(value: Path | ConfigType) -> Path: + # cv.file_ has already resolved the path against the config dir. + return value[CONF_PATH] if isinstance(value, dict) else value -def download_file(url: str, path: Path) -> str: +def download_file(url: str, path: Path) -> Path: # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be # silently ignored on a per-run memo hit anyway (memos key by path). external_files.download_content(url, path) - return str(path) + # Keep the Path: config-hash normalizes Path values under the data dir, + # which a str would dump verbatim and break the CLI/add-on comparison. + return path def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: @@ -93,13 +95,13 @@ def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" -def download_gh_svg(value: str | ConfigType, source: str) -> str: +def download_gh_svg(value: str | ConfigType, source: str) -> Path: mdi_id = value[CONF_ICON] if isinstance(value, dict) else value url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) -def download_image(value: str | ConfigType) -> str: +def download_image(value: str | ConfigType) -> Path: value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) @@ -147,7 +149,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) -def validate_file_shorthand(value: Any) -> str: +def validate_file_shorthand(value: Any) -> Path: value = cv.string_strict(value) if (remote := _parse_remote_shorthand(value)) is not None: return download_file(remote.url, remote.path) @@ -165,7 +167,7 @@ LOCAL_SCHEMA = cv.All( def mdi_schema(source: str) -> cv.All: - def validate_mdi(value: ConfigType) -> str: + def validate_mdi(value: ConfigType) -> Path: return download_gh_svg(value, source) return cv.All( diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py index a9c1684db39..727a4c8c1ef 100644 --- a/tests/unit_tests/components/file/test_image.py +++ b/tests/unit_tests/components/file/test_image.py @@ -5,8 +5,13 @@ from __future__ import annotations from pathlib import Path from unittest.mock import patch +import pytest + +from esphome import yaml_util from esphome.components.file import image as file_image -from esphome.external_files import RemoteFile +from esphome.const import CONF_PATH +from esphome.core import CORE +from esphome.external_files import RemoteFile, url_cache_key from esphome.loader import get_component, get_platform @@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: assert files[1].url == "https://example.com/img.png" +def test_validated_file_values_hash_alike_across_data_dirs( + setup_core: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A CLI and an add-on data dir dump validated image files identically.""" + url = "https://example.com/img.png" + (setup_core / "img.png").touch() + dumps: list[str] = [] + for data_dir in ( + setup_core / ".esphome", + setup_core.parent / f"{setup_core.name}-data", + ): + monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir)) + with patch("esphome.components.file.image.external_files.download_content"): + config = { + "remote": file_image.validate_file_shorthand(url), + "mdi": file_image.validate_file_shorthand("mdi:home"), + "local": file_image.validate_file_shorthand("img.png"), + "local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}), + } + dumps.append( + yaml_util.dump( + config, + sort_keys=True, + relative_to=CORE.config_dir, + data_dir=CORE.data_dir, + ) + ) + assert dumps[0] == dumps[1] + assert dumps[0].splitlines() == [ + "local: img.png", + "local_schema: img.png", + "mdi: .esphome/image/mdi/home.svg", + f"remote: .esphome/image/{url_cache_key(url)}", + ] + + def test_extractor_matches_validator_path(setup_core: Path) -> None: """The path the validator downloads to equals the extractor's path.""" with patch( From 3f7725a6847b15696dc889b79d032f7a524e02c2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:00:05 +1200 Subject: [PATCH 187/759] Bump version to 2026.9.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 331d2f7984b..a5ed253819f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b4 +PROJECT_NUMBER = 2026.9.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 56961253557..8a9e9695859 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b4" +__version__ = "2026.9.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ec1d8fa9535ec51a69ac18592bd2e6b66bbf7f06 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 15 Sep 2026 21:06:40 -0400 Subject: [PATCH 188/759] [mdns] Add runtime service enable/disable API (ESP32 only) (#19325) --- esphome/components/mdns/__init__.py | 24 +++++++ esphome/components/mdns/mdns_component.h | 16 +++++ esphome/components/mdns/mdns_esp32.cpp | 69 ++++++++++++++---- esphome/core/defines.h | 3 + tests/component_tests/mdns/__init__.py | 0 .../mdns/test_service_enable_disable.py | 70 +++++++++++++++++++ 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/mdns/__init__.py create mode 100644 tests/component_tests/mdns/test_service_enable_disable.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index c8020104b37..0fb24fdf1df 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb996..ed06b8e1330 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -63,6 +63,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +115,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd76..48df61326e2 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f6010fd7fa0..b36d39bbefa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -479,6 +479,9 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#ifndef USE_OPENTHREAD +#define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) diff --git a/tests/component_tests/mdns/__init__.py b/tests/component_tests/mdns/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py new file mode 100644 index 00000000000..eacf722f177 --- /dev/null +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -0,0 +1,70 @@ +"""request_service_enable_disable() only opts in on platforms whose mDNS stack +can add and remove services after setup, and tells the caller so.""" + +import pytest + +from esphome.components import mdns +from esphome.const import CONF_DISABLED, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def _set_config( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + config: dict, +) -> None: + set_core_config(platform_framework) + CORE.config = config + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO], +) +def test_esp32_adds_define_and_keeps_services_stored( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is True + # Disabled services must stay stored so they can be re-registered later. + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], +) +def test_other_platforms_return_false( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="no_mdns"), + pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"), + pytest.param( + {"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread" + ), + ], +) +def test_esp32_returns_false_when_services_cannot_be_toggled( + set_core_config: SetCoreConfigCallable, config: dict +) -> None: + _set_config(set_core_config, PlatformFramework.ESP32_IDF, config) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() From d824a32ef1f77c8be80e3d5800c2ca8848585a49 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 20:32:15 -0500 Subject: [PATCH 189/759] [uart_mux] New component to share a UART between a CDC-ACM bridge and local consumers (#19066) Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../cdc_acm_uart/bridge/cdc_acm_uart_bridge.h | 1 + esphome/components/uart_mux/__init__.py | 84 ++++++++++++ esphome/components/uart_mux/uart_mux.cpp | 127 ++++++++++++++++++ esphome/components/uart_mux/uart_mux.h | 109 +++++++++++++++ script/analyze_component_buses.py | 1 + tests/component_tests/uart_mux/__init__.py | 0 tests/component_tests/uart_mux/test_init.py | 42 ++++++ tests/components/uart_mux/common.yaml | 44 ++++++ .../uart_mux/test.esp32-p4-idf.yaml | 2 + .../uart_mux/test.esp32-s2-idf.yaml | 7 + .../uart_mux/test.esp32-s3-idf.yaml | 2 + 12 files changed, 420 insertions(+) create mode 100644 esphome/components/uart_mux/__init__.py create mode 100644 esphome/components/uart_mux/uart_mux.cpp create mode 100644 esphome/components/uart_mux/uart_mux.h create mode 100644 tests/component_tests/uart_mux/__init__.py create mode 100644 tests/component_tests/uart_mux/test_init.py create mode 100644 tests/components/uart_mux/common.yaml create mode 100644 tests/components/uart_mux/test.esp32-p4-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s2-idf.yaml create mode 100644 tests/components/uart_mux/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 246a210c7cb..044d0051193 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -589,6 +589,7 @@ esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow +esphome/components/uart_mux/* @kbx81 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h index 64522c86bd5..405b794653d 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -30,6 +30,7 @@ class CDCACMUARTBridge final : public Component { void set_line_coding(); void set_line_state(bool dtr, bool rts); + uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; } /** * Stop forwarding in both directions and hand the UART back to its configured diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py new file mode 100644 index 00000000000..6c479f0cdd2 --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -0,0 +1,84 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DOMAIN = "uart_mux" +DEPENDENCIES = ["bridge", "uart"] +MULTI_CONF = True + +CONF_BRIDGE_ID = "bridge_id" +CONF_INITIAL_ROUTE = "initial_route" +ROUTE_BRIDGE = "bridge" +ROUTE_LOCAL = "local" + +uart_mux_ns = cg.esphome_ns.namespace("uart_mux") +UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) +SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) +SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) +IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UARTMux), + cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge), + cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of( + ROUTE_BRIDGE, ROUTE_LOCAL, lower=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Two muxes on one bridge would each believe they own the bus. + owned = fv.full_config.get().data.setdefault(DOMAIN, set()) + bridge_id = str(config[CONF_BRIDGE_ID]) + if bridge_id in owned: + raise cv.Invalid( + f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; " + "each bridge supports one mux.", + [CONF_BRIDGE_ID], + ) + owned.add(bridge_id) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + bridge = await cg.get_variable(config[CONF_BRIDGE_ID]) + var = cg.new_Pvariable(config[CONF_ID], bridge) + await cg.register_component(var, config) + if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL: + cg.add(var.set_start_local(True)) + + +UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(UARTMux)} +) + + +automation.register_simple_action( + "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True +) +automation.register_simple_action( + "uart_mux.select_bridge", + SelectBridgeAction, + UART_MUX_ACTION_SCHEMA, + synchronous=True, +) +automation.register_simple_condition( + "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 00000000000..df2ee5ccf59 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -0,0 +1,127 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "uart_mux.h" +#include "esphome/core/log.h" + +#include "driver/uart.h" + +namespace esphome::uart_mux { + +static const char *const TAG = "uart_mux"; + +void UARTMux::setup() { + // A failed UART never assigned its port; nothing behind the mux can work. + if (this->uart_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->settings_ = { + this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(), + this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(), + this->uart_->get_parity(), + }; + this->apply_settings_(); + + if (this->start_local_) { + this->select_local(); + } else { + // loop() only completes hand-offs; the bridge keeps the UART until an action. + this->disable_loop(); + } +} + +void UARTMux::loop() { + if (!this->bridge_->is_paused()) { + return; + } + // Bytes that arrived during the hand-off belong to neither owner. + this->flush_input_(); + this->route_ = Route::ROUTE_LOCAL; + ESP_LOGD(TAG, "UART routed to local consumers"); + this->disable_loop(); +} + +void UARTMux::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Mux:\n" + " Start local: %s\n" + " Route: %s", + YESNO(this->start_local_), + this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local") + : this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local") + : LOG_STR_LITERAL("bridge")); +} + +void UARTMux::load_settings(bool dump_config) { + if (!this->load_settings_warned_) { + this->load_settings_warned_ = true; + ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead"); + } + // Undo whatever the caller set on us. Not re-sampled from the live UART, whose + // fields carry the host's line coding while the bridge owns the bus. + this->apply_settings_(); +} + +void UARTMux::apply_settings_() { + this->baud_rate_ = this->settings_.baud_rate; + this->data_bits_ = this->settings_.data_bits; + this->stop_bits_ = this->settings_.stop_bits; + this->parity_ = this->settings_.parity; + this->rx_full_threshold_ = this->settings_.rx_full_threshold; + this->rx_timeout_ = this->settings_.rx_timeout; + this->rx_buffer_size_ = this->settings_.rx_buffer_size; +} + +void UARTMux::select_local() { + if (this->route_ != Route::ROUTE_BRIDGE) { + return; + } + ESP_LOGD(TAG, "Pausing bridge to route UART locally"); + this->bridge_->pause(); + this->route_ = Route::ROUTE_PENDING_LOCAL; + this->enable_loop(); +} + +void UARTMux::select_bridge() { + if (this->route_ == Route::ROUTE_BRIDGE) { + return; + } + // A bridge that failed setup() has no worker tasks; handing it the bus would kill + // the UART in both directions. + if (this->bridge_->is_failed()) { + ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally"); + return; + } + // While the pause is still pending the bridge's RX task may be inside + // uart_read_bytes() on this port, and nothing local has run, so flush only a + // completed hand-off. + if (this->route_ == Route::ROUTE_LOCAL) { + this->flush_input_(); + } + this->route_ = Route::ROUTE_BRIDGE; + ESP_LOGD(TAG, "UART routed to bridge"); + this->bridge_->resume(); + this->disable_loop(); +} + +void UARTMux::flush_input_() { + // Drain the UART component's one-byte peek cache first: the driver flush does not + // clear it, and draining afterwards could discard a freshly arrived byte instead. + uint8_t discard; + if (this->uart_->available() > 0) { + this->uart_->read_byte(&discard); + } + uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); +} + +void UARTMux::write_array(const uint8_t *data, size_t len) { + if (!this->is_local()) { + ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); + return; + } + this->uart_->write_array(data, len); +} + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 00000000000..1eb23747142 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -0,0 +1,109 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::uart_mux { + +/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local +/// consumers bind to the mux as their UART; it forwards to the hardware UART only +/// while routed locally and reports the route through is_connected(). Routing is +/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount. +class UARTMux final : public uart::UARTComponent, public Component { + public: + explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {} + + void setup() override; + void loop() override; + void dump_config() override; + // Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the + // mirrored framing must exist before anything reads it from us. + float get_setup_priority() const override { return setup_priority::BUS - 0.5f; } + + /// Route locally at boot instead of leaving the UART with the bridge. + void set_start_local(bool start_local) { this->start_local_ = start_local; } + + /// Pause the bridge and route the UART to local consumers once it has stopped. + void select_local(); + /// Route the UART back to the bridge. + void select_bridge(); + bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; } + + // uart::UARTComponent: forwarded while routed locally, inert otherwise. + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); } + bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); } + size_t available() override { return this->is_local() ? this->uart_->available() : 0; } + uart::UARTFlushResult flush() override { + return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + bool is_connected() override { return this->is_local(); } + // Ignored: the bridge's tasks block inside the driver, and reinstalling it would + // pull it out from under them. The framing is the hardware UART's to change. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; + + protected: + enum class Route : uint8_t { + ROUTE_BRIDGE, + ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus + ROUTE_LOCAL, + }; + + // The hardware UART's settings as configured. Taken once at setup, before the + // bridge can overwrite the live fields with a host's line coding. + struct Settings { + uint32_t baud_rate; + size_t rx_full_threshold; + size_t rx_timeout; + size_t rx_buffer_size; + uint8_t data_bits; + uint8_t stop_bits; + uart::UARTParityOptions parity; + }; + + void check_logger_conflict() override {} + void flush_input_(); + // Publish settings_ through the UARTComponent getters. + void apply_settings_(); + + uart::IDFUARTComponent *uart_; + cdc_acm_uart::CDCACMUARTBridge *bridge_; + Settings settings_{}; + Route route_{Route::ROUTE_BRIDGE}; + bool start_local_{false}; + bool load_settings_warned_{false}; +}; + +template class SelectLocalAction final : public Action { + public: + explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_local(); } + + protected: + UARTMux *parent_; +}; + +template class SelectBridgeAction final : public Action { + public: + explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} + void play(const Ts &...) override { this->parent_->select_bridge(); } + + protected: + UARTMux *parent_; +}; + +template class IsLocalCondition final : public Condition { + public: + explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} + bool check(const Ts &...) override { return this->parent_->is_local(); } + + protected: + UARTMux *parent_; +}; + +} // namespace esphome::uart_mux +#endif diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index b805d5155a2..8bbb9ed7f9b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -97,6 +97,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/tests/component_tests/uart_mux/__init__.py b/tests/component_tests/uart_mux/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart_mux/test_init.py b/tests/component_tests/uart_mux/test_init.py new file mode 100644 index 00000000000..2221ec56694 --- /dev/null +++ b/tests/component_tests/uart_mux/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the uart_mux component's final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_BRIDGE_ID = "bridge_id" + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3 + + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3} + ) + + +def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType: + return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)} + + +def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + uart_mux._final_validate(_mux_conf("mux_1", "bridge_1")) + + +def test_rejects_two_muxes_on_one_bridge( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_esp32_s3(set_core_config) + from esphome.components import uart_mux + + uart_mux._final_validate(_mux_conf("mux_0", "bridge_0")) + with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"): + uart_mux._final_validate(_mux_conf("mux_1", "bridge_0")) diff --git a/tests/components/uart_mux/common.yaml b/tests/components/uart_mux/common.yaml new file mode 100644 index 00000000000..3477f78b708 --- /dev/null +++ b/tests/components/uart_mux/common.yaml @@ -0,0 +1,44 @@ +tinyusb: + id: tinyusb_test + on_mount: + - uart_mux.select_bridge: mux_0 + on_unmount: + - uart_mux.select_local: mux_0 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_vendor_id: 0x2345 + +uart: + - id: uart_0 + tx_pin: 14 + rx_pin: 13 + baud_rate: 115200 + +usb_cdc_acm: + interfaces: + - id: cdc_acm_1 + +bridge: + - platform: cdc_acm_uart + id: bridge_0 + uart_id: uart_0 + usb_cdc_acm_id: cdc_acm_1 + +uart_mux: + - id: mux_0 + bridge_id: bridge_0 + initial_route: local + +interval: + - interval: 60s + then: + - if: + condition: + uart_mux.is_local: mux_0 + then: + - lambda: |- + uint8_t byte; + if (id(mux_0).available() && id(mux_0).read_byte(&byte)) { + id(mux_0).write_byte(byte); + } diff --git a/tests/components/uart_mux/test.esp32-p4-idf.yaml b/tests/components/uart_mux/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s2-idf.yaml b/tests/components/uart_mux/test.esp32-s2-idf.yaml new file mode 100644 index 00000000000..5eaa3b38479 --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s2-idf.yaml @@ -0,0 +1,7 @@ +# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares +# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead. +logger: + hardware_uart: UART0 + +packages: + uart_mux: !include common.yaml diff --git a/tests/components/uart_mux/test.esp32-s3-idf.yaml b/tests/components/uart_mux/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..ced6f1158eb --- /dev/null +++ b/tests/components/uart_mux/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + uart_mux: !include common.yaml From a264093d86242919777fd869aa3fa794c3068edb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 15 Sep 2026 21:57:06 -0500 Subject: [PATCH 190/759] [uart] Add IDFUARTComponent::flush_input() and use it in uart_mux (#19336) Co-authored-by: Claude Fable 5.1 --- esphome/components/uart/uart_component_esp_idf.h | 6 ++++++ esphome/components/uart_mux/uart_mux.cpp | 16 ++-------------- esphome/components/uart_mux/uart_mux.h | 1 - 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index d9297bfa34a..3b8603f2ac2 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -37,6 +37,12 @@ class IDFUARTComponent final : public UARTComponent, public Component { uint8_t get_hw_serial_number() { return this->uart_num_; } + /// Discard everything received so far: the peek cache and the driver's RX buffer. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + /** * Load the UART with the current settings. * @param dump_config (Optional, default `true`): True for displaying new settings or diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp index df2ee5ccf59..953e81d5337 100644 --- a/esphome/components/uart_mux/uart_mux.cpp +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -2,8 +2,6 @@ #include "uart_mux.h" #include "esphome/core/log.h" -#include "driver/uart.h" - namespace esphome::uart_mux { static const char *const TAG = "uart_mux"; @@ -36,7 +34,7 @@ void UARTMux::loop() { return; } // Bytes that arrived during the hand-off belong to neither owner. - this->flush_input_(); + this->uart_->flush_input(); this->route_ = Route::ROUTE_LOCAL; ESP_LOGD(TAG, "UART routed to local consumers"); this->disable_loop(); @@ -97,7 +95,7 @@ void UARTMux::select_bridge() { // uart_read_bytes() on this port, and nothing local has run, so flush only a // completed hand-off. if (this->route_ == Route::ROUTE_LOCAL) { - this->flush_input_(); + this->uart_->flush_input(); } this->route_ = Route::ROUTE_BRIDGE; ESP_LOGD(TAG, "UART routed to bridge"); @@ -105,16 +103,6 @@ void UARTMux::select_bridge() { this->disable_loop(); } -void UARTMux::flush_input_() { - // Drain the UART component's one-byte peek cache first: the driver flush does not - // clear it, and draining afterwards could discard a freshly arrived byte instead. - uint8_t discard; - if (this->uart_->available() > 0) { - this->uart_->read_byte(&discard); - } - uart_flush_input(static_cast(this->uart_->get_hw_serial_number())); -} - void UARTMux::write_array(const uint8_t *data, size_t len) { if (!this->is_local()) { ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 1eb23747142..8e32a166434 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -66,7 +66,6 @@ class UARTMux final : public uart::UARTComponent, public Component { }; void check_logger_conflict() override {} - void flush_input_(); // Publish settings_ through the UARTComponent getters. void apply_settings_(); From 160bc29c7c1ea27033ac99cdcf9bf9aa109516d1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:01:34 +1200 Subject: [PATCH 191/759] Bump version to 2026.9.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a5ed253819f..a5d6da63332 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0b5 +PROJECT_NUMBER = 2026.9.0 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 8a9e9695859..e6165bf16b0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0b5" +__version__ = "2026.9.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From b0996ac4045ee93ad5bd3284e35ec90652ca4cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:11:57 -0500 Subject: [PATCH 192/759] [template] Skip the switch optimistic and assumed state setters when they match the default (#19232) --- esphome/components/template/switch/__init__.py | 7 +++++-- .../template/switch/template_switch.h | 1 + .../template/config/switch_defaults.yaml | 18 ++++++++++++++++++ .../template/test_template_switch.py | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/template/config/switch_defaults.yaml create mode 100644 tests/component_tests/template/test_template_switch.py diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 37303abb0d7..0b246869363 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -72,8 +72,11 @@ async def to_code(config): await automation.build_automation( var.get_turn_on_trigger(), [], config[CONF_TURN_ON_ACTION] ) - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) + # optimistic_ and assumed_state_ are false in C++; only emit setters to turn them on. + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + if config[CONF_ASSUMED_STATE]: + cg.add(var.set_assumed_state(True)) @automation.register_action( diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 1714b4f72b9..3b8b6cde424 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -29,6 +29,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { void write_state(bool state) override; TemplateLambda f_; + // Codegen only emits these setters to turn them on bool optimistic_{false}; bool assumed_state_{false}; Trigger<> turn_on_trigger_; diff --git a/tests/component_tests/template/config/switch_defaults.yaml b/tests/component_tests/template/config/switch_defaults.yaml new file mode 100644 index 00000000000..4387fe07a5b --- /dev/null +++ b/tests/component_tests/template/config/switch_defaults.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +switch: + - platform: template + id: plain_switch + turn_on_action: + - logger.log: "on" + - platform: template + id: enabled_switch + optimistic: true + assumed_state: true diff --git a/tests/component_tests/template/test_template_switch.py b/tests/component_tests/template/test_template_switch.py new file mode 100644 index 00000000000..11c6a9cab87 --- /dev/null +++ b/tests/component_tests/template/test_template_switch.py @@ -0,0 +1,17 @@ +"""Tests for the template switch codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_flags_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only true optimistic and assumed_state are set; false is the C++ initializer.""" + main_cpp = generate_main(component_config_path("switch_defaults.yaml")) + + assert "plain_switch->set_optimistic(" not in main_cpp + assert "plain_switch->set_assumed_state(" not in main_cpp + assert "enabled_switch->set_optimistic(true);" in main_cpp + assert "enabled_switch->set_assumed_state(true);" in main_cpp From 1eb445bacbc05c259bcead7a46b4b9c76b777218 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:12:48 -0500 Subject: [PATCH 193/759] [template] Move set_optimistic into the headers (#19278) --- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/lock/template_lock.cpp | 1 - esphome/components/template/lock/template_lock.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index d5e0967e1e3..93ad887e580 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 20c092cda79..cca21046202 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -25,7 +25,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 6e73623ae9b..4a293aab858 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -47,7 +47,6 @@ void TemplateLock::open_latch() { this->prev_trigger_ = &this->open_trigger_; this->open_trigger_.trigger(); } -void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } void TemplateLock::dump_config() { LOG_LOCK("", "Template Lock", this); diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 03e3e86d88e..9b0a1ffe984 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -18,7 +18,7 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *get_lock_trigger() { return &this->lock_trigger_; } Trigger<> *get_unlock_trigger() { return &this->unlock_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 05288b2d4e0..27134fc8b29 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -29,7 +29,6 @@ void TemplateSwitch::write_state(bool state) { if (this->optimistic_) this->publish_state(state); } -void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() { return &this->turn_on_trigger_; } diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 3b8b6cde424..9af8517f9ac 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -17,7 +17,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void loop() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 3ebeec12856..c9aa161b117 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 76c4630aa02..e123f66d7e8 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -23,7 +23,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_stop_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); void set_has_stop(bool has_stop); void set_has_position(bool has_position); From 70097ae02fca195ce31c8bd3ec6db78cd8c1b855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:06 -0500 Subject: [PATCH 194/759] [sds011] Move set_rx_mode_only into the header (#19298) --- esphome/components/sds011/sds011.cpp | 2 -- esphome/components/sds011/sds011.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 1c222e5e803..dfc7857266d 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -106,8 +106,6 @@ void SDS011Component::loop() { } } -void SDS011Component::set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } - void SDS011Component::sds011_write_command_(const uint8_t *command_data) { this->write_byte(SDS011_MSG_HEAD); this->write_byte(SDS011_COMMAND_ID_REQUEST); diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 4f4571ab693..0a896cdc4cd 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -12,7 +12,7 @@ class SDS011Component final : public Component, public uart::UARTDevice { SDS011Component() = default; /// Manually set the rx-only mode. Defaults to false. - void set_rx_mode_only(bool rx_mode_only); + void set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5_sensor) { pm_2_5_sensor_ = pm_2_5_sensor; } void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } From f95a876ef2874642daa3fc1adab30368a7c35b70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:20 -0500 Subject: [PATCH 195/759] [max44009] Move set_mode into the header (#19297) --- esphome/components/max44009/max44009.cpp | 2 -- esphome/components/max44009/max44009.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 6b8bdc8de5e..731f5840563 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -134,6 +134,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { } } -void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } - } // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index b62aed7a567..5eb1555350a 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -16,7 +16,7 @@ class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, pub void setup() override; void dump_config() override; void update() override; - void set_mode(MAX44009Mode mode); + void set_mode(MAX44009Mode mode) { this->mode_ = mode; } bool set_continuous_mode(); bool set_low_power_mode(); From da3ddee767ff317f61d1dc73763cdef00ad3bced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:34 -0500 Subject: [PATCH 196/759] [st7789v] Move set_model_str into the header (#19296) --- esphome/components/st7789v/st7789v.cpp | 2 -- esphome/components/st7789v/st7789v.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index b3a60af8c33..2e07e24522c 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -152,8 +152,6 @@ void ST7789V::update() { this->write_display_data(); } -void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } - void ST7789V::write_display_data() { uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 1b7ba318a6c..4011e607c25 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -110,7 +110,7 @@ class ST7789V final : public display::DisplayBuffer, public spi::SPIDevice { public: - void set_model_str(const char *model_str); + void set_model_str(const char *model_str) { this->model_str_ = model_str; } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_backlight_pin(GPIOPin *backlight_pin) { this->backlight_pin_ = backlight_pin; } From a6fa67b7cac99e36e23d55a39eeed500dcb615ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:13:49 -0500 Subject: [PATCH 197/759] [bang_bang] Move the single store setters into the header (#19287) --- esphome/components/bang_bang/bang_bang_climate.cpp | 6 ------ esphome/components/bang_bang/bang_bang_climate.h | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 5dfb1213429..a1104aa1b2a 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -203,16 +203,10 @@ void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &awa this->away_config_ = away_config; } -void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - Trigger<> *BangBangClimate::get_idle_trigger() { return &this->idle_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() { return &this->cool_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() { return &this->heat_trigger_; } -void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void BangBangClimate::dump_config() { LOG_CLIMATE("", "Bang Bang Climate", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d83257f9f34..fff9bf873ff 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -22,10 +22,10 @@ class BangBangClimate final : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); - void set_supports_cool(bool supports_cool); - void set_supports_heat(bool supports_heat); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config); From 2f76ac6362ae257ba1cc0c3c80828549bcd53f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:05 -0500 Subject: [PATCH 198/759] [thermostat] Move set_default_preset into the header (#19294) --- esphome/components/thermostat/thermostat_climate.cpp | 2 -- esphome/components/thermostat/thermostat_climate.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e830d359c64..f64673e13fa 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1304,8 +1304,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { this->default_custom_preset_ = nullptr; } -void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4dc2a74d8e5..b7d46eae227 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -92,7 +92,7 @@ class ThermostatClimate final : public climate::Climate, public Component { void loop() override; void set_default_preset(const char *custom_preset); - void set_default_preset(climate::ClimatePreset preset); + void set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { this->on_boot_restore_from_ = on_boot_restore_from; } From ee6e675d581846ce832b51064b13cf3b4550deb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:22 -0500 Subject: [PATCH 199/759] [esp32_camera] Move the single store setters into the header (#19285) --- .../components/esp32_camera/esp32_camera.cpp | 15 ---------- .../components/esp32_camera/esp32_camera.h | 30 +++++++++---------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 598fe61d464..03fdbc4de7c 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -433,25 +433,10 @@ void ESP32Camera::set_pixel_format(ESP32CameraPixelFormat format) { } } void ESP32Camera::set_jpeg_quality(uint8_t quality) { this->config_.jpeg_quality = quality; } -void ESP32Camera::set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } -void ESP32Camera::set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } -void ESP32Camera::set_contrast(int contrast) { this->contrast_ = contrast; } -void ESP32Camera::set_brightness(int brightness) { this->brightness_ = brightness; } -void ESP32Camera::set_saturation(int saturation) { this->saturation_ = saturation; } -void ESP32Camera::set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* set exposure parameters */ -void ESP32Camera::set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } -void ESP32Camera::set_aec2(bool aec2) { this->aec2_ = aec2; } -void ESP32Camera::set_ae_level(int ae_level) { this->ae_level_ = ae_level; } -void ESP32Camera::set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* set gains parameters */ -void ESP32Camera::set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } -void ESP32Camera::set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } -void ESP32Camera::set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* set white balance */ -void ESP32Camera::set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* set test mode */ -void ESP32Camera::set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* set fps */ void ESP32Camera::set_max_update_interval(uint32_t max_update_interval) { this->max_update_interval_ = max_update_interval; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 83dab5f77a3..9ff309ad4a0 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -140,25 +140,25 @@ class ESP32Camera final : public camera::Camera { void set_pixel_format(ESP32CameraPixelFormat format); void set_frame_size(ESP32CameraFrameSize size); void set_jpeg_quality(uint8_t quality); - void set_vertical_flip(bool vertical_flip); - void set_horizontal_mirror(bool horizontal_mirror); - void set_contrast(int contrast); - void set_brightness(int brightness); - void set_saturation(int saturation); - void set_special_effect(ESP32SpecialEffect effect); + void set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } + void set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } + void set_contrast(int contrast) { this->contrast_ = contrast; } + void set_brightness(int brightness) { this->brightness_ = brightness; } + void set_saturation(int saturation) { this->saturation_ = saturation; } + void set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* -- exposure */ - void set_aec_mode(ESP32GainControlMode mode); - void set_aec2(bool aec2); - void set_ae_level(int ae_level); - void set_aec_value(uint32_t aec_value); + void set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } + void set_aec2(bool aec2) { this->aec2_ = aec2; } + void set_ae_level(int ae_level) { this->ae_level_ = ae_level; } + void set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* -- gains */ - void set_agc_mode(ESP32GainControlMode mode); - void set_agc_value(uint8_t agc_value); - void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling); + void set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } + void set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } + void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* -- white balance */ - void set_wb_mode(ESP32WhiteBalanceMode mode); + void set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* -- test */ - void set_test_pattern(bool test_pattern); + void set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* -- framerates */ void set_max_update_interval(uint32_t max_update_interval); void set_idle_update_interval(uint32_t idle_update_interval); From cc9f4730b43460ad3782a1b1f7ff1add96992e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:14:39 -0500 Subject: [PATCH 200/759] [mqtt_subscribe] Move set_qos into the headers (#19282) --- .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp | 1 - .../components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h | 2 +- .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp | 1 - .../mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 40b5b46e1d2..afb725feb64 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -25,7 +25,6 @@ void MQTTSubscribeSensor::setup() { } float MQTTSubscribeSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeSensor::dump_config() { LOG_SENSOR("", "MQTT Subscribe", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 739e8456ee8..b0a8a0a78a2 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -18,7 +18,7 @@ class MQTTSubscribeSensor final : public sensor::Sensor, public Component { void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index edc197671e6..470e08d59ae 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -15,7 +15,6 @@ void MQTTSubscribeTextSensor::setup() { this->qos_); } float MQTTSubscribeTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeTextSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "MQTT Subscribe Text Sensor", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 8641825fca9..dc02eb5d187 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -17,7 +17,7 @@ class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Com void setup() override; void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; From 031b9804215f0fae19bbcfd9b6f98773f1ee9b89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:15:07 -0500 Subject: [PATCH 201/759] [web_server] Move the CSS and JS setters into the header (#19286) --- esphome/components/web_server/web_server.cpp | 7 ------- esphome/components/web_server/web_server.h | 8 ++++---- esphome/components/web_server/web_server_v1.cpp | 4 ---- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e54..1683492da76 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -336,13 +336,6 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} -#ifdef USE_WEBSERVER_CSS_INCLUDE -void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } -#endif -#ifdef USE_WEBSERVER_JS_INCLUDE -void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_include; } -#endif - json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 0fbe4ec5515..d60b39278aa 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -204,14 +204,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_url The url to the web server stylesheet. */ - void set_css_url(const char *css_url); + void set_css_url(const char *css_url) { this->css_url_ = css_url; } /** Set the URL to the script that's embedded in the index page. Defaults to * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ - void set_js_url(const char *js_url); + void set_js_url(const char *js_url) { this->js_url_ = js_url; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE @@ -219,7 +219,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_include Local path to web server script. */ - void set_css_include(const char *css_include); + void set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE @@ -227,7 +227,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param js_include Local path to web server script. */ - void set_js_include(const char *js_include); + void set_js_include(const char *js_include) { this->js_include_ = js_include; } #endif /** Determine whether internal components should be displayed on the web server. diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 85a4e80541b..08654e353a9 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -69,10 +69,6 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print(""); } -void WebServer::set_css_url(const char *css_url) { this->css_url_ = css_url; } - -void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } - void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); const auto &title = App.get_name(); From 456119ec38e1162bb01292c6e8d8c7e08b7fe67c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:05 -0500 Subject: [PATCH 202/759] [graphical_display_menu] Move set_display and set_font into the header (#19291) --- .../graphical_display_menu/graphical_display_menu.cpp | 4 ---- .../graphical_display_menu/graphical_display_menu.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index f0642d2e8c7..d261c488557 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -57,10 +57,6 @@ void GraphicalDisplayMenu::dump_config() { } } -void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } - -void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } - void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ccdf3d304c5..13c0f9d73f8 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -38,8 +38,8 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen void setup() override; void dump_config() override; - void set_display(display::Display *display); - void set_font(display::BaseFont *font); + void set_display(display::Display *display) { this->display_ = display; } + void set_font(display::BaseFont *font) { this->font_ = font; } template void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } void set_foreground_color(Color foreground_color); void set_background_color(Color background_color); From 7e3a1cf272470b19ab3be0a87f6e3d665118f028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:10 -0500 Subject: [PATCH 203/759] [tsl2561] Move set_is_cs_package into the header (#19289) --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 963114b230c..5c53ed607ff 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -146,7 +146,6 @@ void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time this->integration_time_ = integration_time; } void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } -void TSL2561Sensor::set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8997d19f53a..8f6251c1342 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -59,7 +59,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param package_cs Is this a CS package. */ - void set_is_cs_package(bool package_cs); + void set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) From 534b4a0f44fa7434abcf3224f13b84783ab2b811 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:18 -0500 Subject: [PATCH 204/759] [template] Move the cover and valve set_has_* setters into the headers (#19283) --- esphome/components/template/cover/template_cover.cpp | 4 ---- esphome/components/template/cover/template_cover.h | 8 ++++---- esphome/components/template/valve/template_valve.cpp | 4 ---- esphome/components/template/valve/template_valve.h | 6 +++--- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 93ad887e580..1efab9c5b86 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -116,10 +116,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; } -void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } -void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index cca21046202..e69c91bf092 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -27,10 +27,10 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_tilt(bool has_tilt); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index c9aa161b117..f35fdbeaf1c 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -110,10 +110,6 @@ ValveTraits TemplateValve::get_traits() { Trigger *TemplateValve::get_position_trigger() { return &this->position_trigger_; } -void TemplateValve::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateValve::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateValve::set_has_position(bool has_position) { this->has_position_ = has_position; } - void TemplateValve::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index e123f66d7e8..9c39a3624c1 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -25,9 +25,9 @@ class TemplateValve final : public valve::Valve, public Component { Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_toggle(bool has_toggle); + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateValveRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; From 1e8f5fa481d3f375d42a980bf9e3f1fd24b95716 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:24 -0500 Subject: [PATCH 205/759] [haier] Move set_send_wifi into the header (#19299) --- esphome/components/haier/haier_base.cpp | 2 -- esphome/components/haier/haier_base.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 48f72dc16b9..87f9331d555 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -190,8 +190,6 @@ void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); } -void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } - void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index db4c1abceb3..18ddbcc1cc5 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -71,7 +71,7 @@ class HaierClimateBase : public esphome::Component, }; bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); - void set_send_wifi(bool send_wifi); + void set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } void send_custom_command(const haier_protocol::HaierMessage &message); template void add_status_message_callback(F &&callback) { this->status_message_callback_.add(std::forward(callback)); From d186b4ba9f8dfb6b78b0a77c67467d980f1b7342 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:42 -0500 Subject: [PATCH 206/759] [openthread] Move set_mdns into the header (#19295) --- esphome/components/openthread/openthread.cpp | 2 -- esphome/components/openthread/openthread.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index b98f1091724..ae896fcfeea 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,8 +227,6 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } -void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } - bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index f4c6d0962ae..b83ffdb6af6 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -90,7 +90,7 @@ extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguide class OpenThreadSrpComponent final : public Component { public: - void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; From 37a08e6cb6f1ef57e1f6069e59ba09cd2a958ce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:47 -0500 Subject: [PATCH 207/759] [lc709203f] Move the single store setters into the header (#19290) --- esphome/components/lc709203f/lc709203f.cpp | 4 ---- esphome/components/lc709203f/lc709203f.h | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a5dda6ca437..36e5bce8e3c 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -275,8 +275,4 @@ void Lc709203f::set_pack_size(uint16_t pack_size) { // not cause an error or crash, so I am not doing any additional checking here. } -void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } - -void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } - } // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 46f773873af..e9c60e285f5 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -26,8 +26,8 @@ class Lc709203f final : public sensor::Sensor, public PollingComponent, public i void dump_config() override; void set_pack_size(uint16_t pack_size); - void set_thermistor_b_constant(uint16_t b_constant); - void set_pack_voltage(LC709203FBatteryVoltage pack_voltage); + void set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } + void set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) { battery_remaining_sensor_ = battery_remaining_sensor; From fdb86cbd2ea6db0501c5ce8c04d71aed9cd2d8b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:16:53 -0500 Subject: [PATCH 208/759] [tsl2591] Move the single store setters into the header (#19288) --- esphome/components/tsl2591/tsl2591.cpp | 6 ------ esphome/components/tsl2591/tsl2591.h | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 2a5d6a4ee46..a741b087971 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -200,8 +200,6 @@ void TSL2591Component::set_infrared_sensor(sensor::Sensor *infrared_sensor) { this->infrared_sensor_ = infrared_sensor; } -void TSL2591Component::set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } - void TSL2591Component::set_full_spectrum_sensor(sensor::Sensor *full_spectrum_sensor) { this->full_spectrum_sensor_ = full_spectrum_sensor; } @@ -242,10 +240,6 @@ void TSL2591Component::set_integration_time_and_gain(TSL2591IntegrationTime inte } } -void TSL2591Component::set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } - -void TSL2591Component::set_name(const char *name) { this->name_ = name; } - bool TSL2591Component::is_adc_valid() { uint8_t status; if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_STATUS, &status)) { diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 3fde3404124..1e699329eb3 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -111,13 +111,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { * * @param enable Enable or disable power save mode. */ - void set_power_save_mode(bool enable); + void set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } /** Sets the name for this instance of the device. * * @param name The user-friendly name. */ - void set_name(const char *name); + void set_name(const char *name) { this->name_ = name; } /** Sets the device and glass attenuation factors. * @@ -235,7 +235,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. */ void set_infrared_sensor(sensor::Sensor *infrared_sensor); /** Used by ESPHome framework. */ - void set_visible_sensor(sensor::Sensor *visible_sensor); + void set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } /** Used by ESPHome framework. */ void set_calculated_lux_sensor(sensor::Sensor *calculated_lux_sensor); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ From 02435e7455eef3e3866d890d6bf0b308b30f1289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:17:12 -0500 Subject: [PATCH 209/759] [waveshare_epaper] Move set_full_update_every into the header (#19280) --- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 4 ---- esphome/components/waveshare_epaper/waveshare_epaper.h | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 14ff5ed53cc..93f23424c01 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -2183,8 +2183,6 @@ void GDEW029T5::write_lut_(const uint8_t *lut, const uint8_t size) { this->end_data_(); } -void GDEW029T5::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } - int GDEW029T5::get_width_internal() { return 128; } int GDEW029T5::get_height_internal() { return 296; } void GDEW029T5::dump_config() { @@ -2523,7 +2521,6 @@ void HOT GDEY042T81::display() { ESP_LOGD(TAG, "Set the display back to deep sleep"); this->deep_sleep(); } -void GDEY042T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY042T81::get_width_internal() { return 400; } int GDEY042T81::get_height_internal() { return 300; } uint32_t GDEY042T81::idle_timeout_() { return 5000; } @@ -3156,7 +3153,6 @@ void HOT GDEY0583T81::display() { this->deep_sleep(); } -void GDEY0583T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY0583T81::get_width_internal() { return 648; } int GDEY0583T81::get_height_internal() { return 480; } uint32_t GDEY0583T81::idle_timeout_() { return 5000; } diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index fa3737238e6..7e16ce3dc30 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -272,7 +272,7 @@ class GDEW029T5 : public WaveshareEPaper { void dump_config() override; void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: void init_display_(); @@ -503,7 +503,7 @@ class GDEY042T81 : public WaveshareEPaper { this->data(0x01); } - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: uint32_t full_update_every_{30}; @@ -695,7 +695,7 @@ class GDEY0583T81 : public WaveshareEPaper { void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: int get_width_internal() override; From 7ed4950c695fa680f0473e55027ee3265bfc1441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:22 -0500 Subject: [PATCH 210/759] [bme280_base][bme680][bmp280_base] Move set_iir_filter into the headers (#19281) --- esphome/components/bme280_base/bme280_base.cpp | 1 - esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme680/bme680.cpp | 1 - esphome/components/bme680/bme680.h | 2 +- esphome/components/bmp280_base/bmp280_base.cpp | 1 - esphome/components/bmp280_base/bmp280_base.h | 2 +- 6 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 0f7e42cce3e..11c796352a3 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -341,7 +341,6 @@ void BME280Component::set_pressure_oversampling(BME280Oversampling pressure_over void BME280Component::set_humidity_oversampling(BME280Oversampling humidity_over_sampling) { this->humidity_oversampling_ = humidity_over_sampling; } -void BME280Component::set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BME280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->read_byte(a_register, &data); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 7fe5f7401da..8b4906b7b77 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -69,7 +69,7 @@ class BME280Component : public PollingComponent { /// Set the oversampling value for the humidity sensor. Default is 16x. void set_humidity_oversampling(BME280Oversampling humidity_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BME280IIRFilter iir_filter); + void set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 164424de096..bac8ed8a5a8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -503,7 +503,6 @@ void BME680Component::set_pressure_oversampling(BME680Oversampling pressure_over void BME680Component::set_humidity_oversampling(BME680Oversampling humidity_oversampling) { this->humidity_oversampling_ = humidity_oversampling; } -void BME680Component::set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_duration) { this->heater_temperature_ = heater_temperature; this->heater_duration_ = heater_duration; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index a274578fc18..e401d036595 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -74,7 +74,7 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the humidity oversampling value. Defaults to 16X. void set_humidity_oversampling(BME680Oversampling humidity_oversampling); /// Set the IIR Filter value. Defaults to no IIR Filter. - void set_iir_filter(BME680IIRFilter iir_filter); + void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 1dae5a689e6..34e1d671019 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -254,7 +254,6 @@ void BMP280Component::set_temperature_oversampling(BMP280Oversampling temperatur void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over_sampling) { this->pressure_oversampling_ = pressure_over_sampling; } -void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->bmp_read_byte(a_register, &data); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 3bf1edab043..860fff6b4b1 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -59,7 +59,7 @@ class BMP280Component : public PollingComponent { /// Set the oversampling value for the pressure sensor. Default is 16x. void set_pressure_oversampling(BMP280Oversampling pressure_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BMP280IIRFilter iir_filter); + void set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void setup() override; void dump_config() override; From 4fba837d408e03dbab50849c441112a12f2d9d4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:18:32 -0500 Subject: [PATCH 211/759] [adc] Move set_sampling_mode into the header (#19293) --- esphome/components/adc/adc_sensor.h | 2 +- esphome/components/adc/adc_sensor_common.cpp | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 71318987479..46b7e7a2ffd 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -94,7 +94,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v /// - SamplingMode::MIN: Use the lowest sample value /// - SamplingMode::MAX: Use the highest sample value /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. - void set_sampling_mode(SamplingMode sampling_mode); + void set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } /// Perform a single ADC sampling operation and return the measured value. /// This function handles raw readings, calibration, and averaging as needed. diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 5ca58df10e0..70211000c3d 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -76,6 +76,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { } } -void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } - } // namespace esphome::adc From d61e0e46952c6fc77286bad0dd19f4d4d0e60340 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:26:51 -0500 Subject: [PATCH 212/759] [tsl2561][tsl2591] Move set_gain into the headers (#19284) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/tsl2561/tsl2561.cpp | 1 - esphome/components/tsl2561/tsl2561.h | 2 +- esphome/components/tsl2591/tsl2591.cpp | 2 -- esphome/components/tsl2591/tsl2591.h | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 5c53ed607ff..4e4d403488d 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -145,7 +145,6 @@ float TSL2561Sensor::get_integration_time_ms_() { void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time) { this->integration_time_ = integration_time; } -void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8f6251c1342..0800b87c461 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -51,7 +51,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param gain The new gain. */ - void set_gain(TSL2561Gain gain); + void set_gain(TSL2561Gain gain) { this->gain_ = gain; } /** The "CS" package of this sensor has a slightly different formula for * converting the raw values. Use this setting to indicate that this is a CS diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index a741b087971..d147aae88ab 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -216,8 +216,6 @@ void TSL2591Component::set_integration_time(TSL2591IntegrationTime integration_t this->integration_time_ = integration_time; } -void TSL2591Component::set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } - void TSL2591Component::set_device_and_glass_attenuation_factors(float device_factor, float glass_attenuation_factor) { this->device_factor_ = device_factor; this->glass_attenuation_factor_ = glass_attenuation_factor; diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 1e699329eb3..c65fc5f6e54 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -241,7 +241,7 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. Does NOT actually set the value on the device. */ void set_integration_time(TSL2591IntegrationTime integration_time); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ - void set_gain(TSL2591ComponentGain gain); + void set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } /** Used by ESPHome framework. */ void setup() override; /** Used by ESPHome framework. */ From f0315ea1ecaafce83c1439f29a787f0680328e9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 00:38:36 -0500 Subject: [PATCH 213/759] [template][modbus_controller] Move set_assumed_state into the headers (#19279) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/modbus_controller/switch/modbus_switch.cpp | 2 -- esphome/components/modbus_controller/switch/modbus_switch.h | 2 +- esphome/components/template/cover/template_cover.cpp | 1 - esphome/components/template/cover/template_cover.h | 2 +- esphome/components/template/switch/template_switch.cpp | 1 - esphome/components/template/switch/template_switch.h | 2 +- esphome/components/template/valve/template_valve.cpp | 1 - esphome/components/template/valve/template_valve.h | 2 +- 8 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 7bf45366c0a..f2aae201f33 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -25,8 +25,6 @@ void ModbusSwitch::setup() { } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } -void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(std::span data) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 688a620bac1..b98543532e6 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -31,7 +31,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void setup() override; void write_state(bool state) override; void dump_config() override; - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->set_controller_(parent); } diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 1efab9c5b86..1bf057da5b5 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,7 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index e69c91bf092..d3096ba86f9 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -26,7 +26,7 @@ class TemplateCover final : public cover::Cover, public Component { Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 27134fc8b29..edd753d3d2b 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -53,6 +53,5 @@ void TemplateSwitch::dump_config() { LOG_SWITCH("", "Template Switch", this); ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } } // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 9af8517f9ac..6dc073e4b37 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -18,7 +18,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index f35fdbeaf1c..50906876399 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,7 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() { return &this->open_trigger_; } diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 9c39a3624c1..504fdb2fbaf 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -24,7 +24,7 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } void set_has_position(bool has_position) { this->has_position_ = has_position; } void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } From 2ffa57c907585593bc26a8a95b487cc3ab9c2985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:07:27 -0500 Subject: [PATCH 214/759] [esp32_ble_tracker] Initialize ESPBTClient::app_id (#19199) --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 1a424a4a8e8..aa470983df9 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -135,7 +135,7 @@ class ESPBTClient : public ESPBTDeviceListener { void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout - uint8_t app_id; // App IDs are small integers assigned sequentially + uint8_t app_id{0}; // App IDs are small integers assigned sequentially protected: /// Set state without IDLE handling - use for direct state transitions. From f30a3bc3e13bf96b3de41f72d67a1dab2b7256d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:21 -0500 Subject: [PATCH 215/759] [bluetooth_connection] Use a user provided default constructor for BluetoothConnection (#19200) --- .../components/bluetooth_connection/bluetooth_connection_hub.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 47181e81a73..4c87b876c3a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -37,6 +37,9 @@ enum class PendingAck : uint8_t { class BluetoothConnection final : public ble_device_base::GattClientListener { public: + // User provided, not "= default": `new(p) BluetoothConnection()` would zero-fill .bss that is already zero. + BluetoothConnection() {} + /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; From ad20711a22d8c36e12d165549330825281777ae1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:24 -0500 Subject: [PATCH 216/759] [restart] Use a user provided default constructor for RestartSwitch (#19204) --- esphome/components/restart/switch/restart_switch.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index dc9ec8eadcd..03cf03f1662 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) RestartSwitch()` would zero-fill .bss that is already zero. + RestartSwitch() {} + void dump_config() override; protected: From f686fb606beed1dd3233b6eeb57cfede0eb8e471 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:30 -0500 Subject: [PATCH 217/759] [gpio] Use a user provided default constructor for GPIOSwitch (#19203) --- esphome/components/gpio/switch/gpio_switch.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 7ed0de7c6f0..e7323e6e937 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -9,6 +9,9 @@ namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) GPIOSwitch()` would zero-fill .bss that is already zero. + GPIOSwitch() {} + void set_pin(GPIOPin *pin) { pin_ = pin; } // ========== INTERNAL METHODS ========== @@ -25,7 +28,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK FixedVector interlock_; uint32_t interlock_wait_time_{0}; From 5198ef8dd8a6b53ba91f5a00bf6c4a6ab25ab974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:43 -0500 Subject: [PATCH 218/759] [binary_sensor] Use a user provided default constructor for DelayedOnFilter (#19205) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 6887de35e1d..bb974fe1322 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -53,6 +53,9 @@ class DelayedOnOffFilter final : public Filter { class DelayedOnFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero. + DelayedOnFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 11ce3594c0725fc3eb24776ed70f5336f552e103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:10:49 -0500 Subject: [PATCH 219/759] [binary_sensor] Use a user provided default constructor for DelayedOffFilter (#19206) --- esphome/components/binary_sensor/filter.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index bb974fe1322..83f4a9b772f 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -66,6 +66,9 @@ class DelayedOnFilter : public Filter { class DelayedOffFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOffFilter()` would zero-fill .bss that is already zero. + DelayedOffFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 274bfe7aeeabd1976867d6cc972a176f4a871807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:15:51 -0500 Subject: [PATCH 220/759] [binary_sensor] Use a user provided default constructor (#19111) --- esphome/components/binary_sensor/binary_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 28c156763a8..a96113b520e 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor() = default; + // User provided, not "= default": `new(p) BinarySensor()` would zero-fill .bss that is already zero. + explicit BinarySensor() {} const bool &get_state() const override { return this->state; } void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } From 42aea9efbc0516a028fe9f101a24218e1fb1c692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:44 -0500 Subject: [PATCH 221/759] [ld2450] Use a user provided default constructor for RestartButton (#19183) --- esphome/components/ld2450/button/restart_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index 9219011f8ba..87b1a2bbd1e 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: - RestartButton() = default; + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} protected: void press_action() override; From 644f0ededbf657b99d839ecb46460d589cb6446c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:47 -0500 Subject: [PATCH 222/759] [ld2450] Use a user provided default constructor for FactoryResetButton (#19182) --- esphome/components/ld2450/button/factory_reset_button.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 392fc67ffdd..71dc19a6cd3 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: - FactoryResetButton() = default; + // User provided, not "= default": `new(p) FactoryResetButton()` would zero-fill .bss that is already zero. + FactoryResetButton() {} protected: void press_action() override; From d16a1ec288ed53ef296a1f122a92ecbc8fe17224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:53 -0500 Subject: [PATCH 223/759] [ld2412] Use a user provided default constructor for DistanceResolutionSelect (#19185) --- esphome/components/ld2412/select/distance_resolution_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index be8dba90b5d..d1bc15dea93 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class DistanceResolutionSelect final : public select::Select, public Parented { public: - DistanceResolutionSelect() = default; + // User provided, not "= default": `new(p) DistanceResolutionSelect()` would zero-fill .bss that is already zero. + DistanceResolutionSelect() {} protected: void control(size_t index) override; From 711d016a5099071884a150069cdb46718ba867ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:16:58 -0500 Subject: [PATCH 224/759] [ld2412] Use a user provided default constructor for BaudRateSelect (#19184) --- esphome/components/ld2412/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 46ec9be1d1e..527b1a1e934 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BaudRateSelect final : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From e77a8525993cfdaaee6d935ecf5ae2c9a9517e70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:03 -0500 Subject: [PATCH 225/759] [ld2412] Use a user provided default constructor for LightOutControlSelect (#19186) --- esphome/components/ld2412/select/light_out_control_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index c8988fda78e..0867f3b1c2e 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightOutControlSelect final : public select::Select, public Parented { public: - LightOutControlSelect() = default; + // User provided, not "= default": `new(p) LightOutControlSelect()` would zero-fill .bss that is already zero. + LightOutControlSelect() {} protected: void control(size_t index) override; From 7d1c9d648830cfe1eca3be1f7567cb22443a58a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:08 -0500 Subject: [PATCH 226/759] [ld2450] Use a user provided default constructor for BaudRateSelect (#19187) --- esphome/components/ld2450/select/baud_rate_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index cb531181707..af4c477dff2 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; From 4d648bf872ece7a17a778126f002a98f3e5ef886 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:13 -0500 Subject: [PATCH 227/759] [ld2450] Use a user provided default constructor for BluetoothSwitch (#19191) --- esphome/components/ld2450/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3d48a89b57f..8b118a7b8c1 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 794aefb2761265c177ed949095819991282c1814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:17:17 -0500 Subject: [PATCH 228/759] [ld2412] Use a user provided default constructor for BluetoothSwitch (#19189) --- esphome/components/ld2412/switch/bluetooth_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 8fd4a86e43c..e753613cdfa 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BluetoothSwitch final : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; From 313d0c1eb3f35f515d326f8ceaec788241fd7c66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:22 -0500 Subject: [PATCH 229/759] [number] Initialize Number::state (#19114) --- esphome/components/number/number.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 579d488cf06..b697e770be0 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -28,7 +28,7 @@ class Number; */ class Number : public EntityBase { public: - float state; + float state{}; void publish_state(float state); From bd47f04479c692b368a6af8799c1725ae2e88008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:51 -0500 Subject: [PATCH 230/759] [ld2450] Use a user provided default constructor for PresenceTimeoutNumber (#19195) --- esphome/components/ld2450/number/presence_timeout_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index 09c8afca55b..8c44fa39dc0 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: - PresenceTimeoutNumber() = default; + // User provided, not "= default": `new(p) PresenceTimeoutNumber()` would zero-fill .bss that is already zero. + PresenceTimeoutNumber() {} protected: void control(float value) override; From b0f87615a23756c7cb22bd5e50859acee87c87f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:19:54 -0500 Subject: [PATCH 231/759] [ld2450] Use a user provided default constructor for ZoneTypeSelect (#19188) --- esphome/components/ld2450/select/zone_type_select.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 566346eb482..cf79c2324dc 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: - ZoneTypeSelect() = default; + // User provided, not "= default": `new(p) ZoneTypeSelect()` would zero-fill .bss that is already zero. + ZoneTypeSelect() {} protected: void control(size_t index) override; From 43e35c4fc26259d984f3f57440d24df8b9f233cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:20:10 -0500 Subject: [PATCH 232/759] [ld2412] Use a user provided default constructor for EngineeringModeSwitch (#19190) --- esphome/components/ld2412/switch/engineering_mode_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index defeb4c76ba..279128ddbc5 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: - EngineeringModeSwitch() = default; + // User provided, not "= default": `new(p) EngineeringModeSwitch()` would zero-fill .bss that is already zero. + EngineeringModeSwitch() {} protected: void write_state(bool state) override; From 2fde80454f170f8aa3389ed8702c3a43c805e3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:30 -0500 Subject: [PATCH 233/759] [ld2412] Use a user provided default constructor for LightThresholdNumber (#19193) --- esphome/components/ld2412/number/light_threshold_number.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index f62d523af38..710b47957c1 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightThresholdNumber final : public number::Number, public Parented { public: - LightThresholdNumber() = default; + // User provided, not "= default": `new(p) LightThresholdNumber()` would zero-fill .bss that is already zero. + LightThresholdNumber() {} protected: void control(float value) override; From 511095d3da567445695364db4305dc53ef5667cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:21:58 -0500 Subject: [PATCH 234/759] [scd4x] Use a user provided default constructor for PerformForcedCalibrationAction (#19176) --- esphome/components/scd4x/automation.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index 4746c0c879c..e0cc04e2cb7 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -9,6 +9,10 @@ namespace esphome::scd4x { template class PerformForcedCalibrationAction final : public Action, public Parented { public: + // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already + // zero. + PerformForcedCalibrationAction() {} + void play(const Ts &...x) override { if (this->value_.has_value()) { this->parent_->perform_forced_calibration(this->value_.value(x...)); From e50428fce596f0f671123060cd85cd9ec075c68b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:22:34 -0500 Subject: [PATCH 235/759] [safe_mode] Use a user provided default constructor for SafeModeComponent (#19163) --- esphome/components/safe_mode/safe_mode.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 0633c92a789..903d9eb79fd 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,6 +17,9 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: + // User provided, not "= default": `new(p) SafeModeComponent()` would zero-fill .bss that is already zero. + SafeModeComponent() {} + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode From 9162dc38cee7d4a0ed08f45d4d2a1bc30278ddcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 01:23:50 -0500 Subject: [PATCH 236/759] [bluetooth_connection] Use a user provided default constructor for BluedroidGattClient (#19201) --- .../bluetooth_connection/bluetooth_connection_bluedroid.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index f285260e763..a4e9edec233 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -31,6 +31,9 @@ class BluetoothConnection; // void disconnect() cannot overload with an int-returning twin. class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { public: + // User provided, not "= default": `new(p) BluedroidGattClient()` would zero-fill .bss that is already zero. + BluedroidGattClient() {} + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; // Lifecycle of one connection attempt's service search. From 9a76f1be4fc435adf670f04e51a682891988d01f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:21 -0500 Subject: [PATCH 237/759] [core] Give StaticVector a user provided default constructor (#19110) --- esphome/core/helpers.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b1f24b25a3d..cfc92932a9d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -242,8 +242,9 @@ template class StaticVector { size_t count_{0}; public: - // Default constructor - StaticVector() = default; + // User provided, not "= default": otherwise `StaticVector<...> x_{}` members + // value-initialize and memset data_, defeating the comment above. + constexpr StaticVector() noexcept {} // Iterator range constructor template StaticVector(InputIt first, InputIt last) { From 0c1cf6d0a9e6c07e431fcdd1ae13a4c5c5ae29c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:27 -0500 Subject: [PATCH 238/759] [template] Use a user provided default constructor for TemplateBinarySensor (#19128) --- .../components/template/binary_sensor/template_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index c78a95e0e36..e1a089b44ca 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: + // User provided, not "= default": `new(p) TemplateBinarySensor()` would zero-fill .bss that is already zero. + TemplateBinarySensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; From 8ffa10f86ced8d6e8d08e8be591e6a594d39a282 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:33 -0500 Subject: [PATCH 239/759] [version] Use a user provided default constructor for VersionTextSensor (#19139) --- esphome/components/version/version_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 96f72ad035b..7ff6ac4d352 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,6 +7,8 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: + // User provided, not "= default": `new(p) VersionTextSensor()` would zero-fill .bss that is already zero. + VersionTextSensor() {} void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; From 7f61909415fc5e921ecaced7069d4204f339dc9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:42 -0500 Subject: [PATCH 240/759] [binary_sensor] Use a user provided default constructor for SettleFilter (#19135) --- esphome/components/binary_sensor/filter.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 83f4a9b772f..1ec255d63d6 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -149,6 +149,8 @@ class StatelessLambdaFilter : public Filter { class SettleFilter : public Filter { public: + // User provided, not "= default": `new(p) SettleFilter()` would zero-fill .bss that is already zero. + SettleFilter() {} optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } From 14e640c90390588fe5f4f6e2535a7cd37aa9845e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:48 -0500 Subject: [PATCH 241/759] [internal_temperature] Use a user provided default constructor for InternalTemperatureSensor (#19141) --- esphome/components/internal_temperature/internal_temperature.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 90831cf211d..6a9889ef29c 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -13,6 +13,9 @@ namespace esphome::internal_temperature { class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) InternalTemperatureSensor()` would zero-fill .bss that is already zero. + InternalTemperatureSensor() {} + #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; #endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) From ba50e23b81666912688a970036e84d1a969aad44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:32:58 -0500 Subject: [PATCH 242/759] [uptime] Use a user provided default constructor for UptimeSecondsSensor (#19140) --- esphome/components/uptime/sensor/uptime_seconds_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index b0b12954b28..92d475e62ec 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -7,6 +7,8 @@ namespace esphome::uptime { class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) UptimeSecondsSensor()` would zero-fill .bss that is already zero. + UptimeSecondsSensor() {} void update() override; void dump_config() override; From 25a3f7068e09f9b065da3ad6a63f0797f4825e46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:01 -0500 Subject: [PATCH 243/759] [status] Use a user provided default constructor for StatusBinarySensor (#19138) --- esphome/components/status/status_binary_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 28cf4cd0832..3c25a9e57de 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -7,6 +7,8 @@ namespace esphome::status { class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: + // User provided, not "= default": `new(p) StatusBinarySensor()` would zero-fill .bss that is already zero. + StatusBinarySensor() {} void update() override; void setup() override; From df4176f9e1f1551b01a75ad6689c17042142af61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:08 -0500 Subject: [PATCH 244/759] [uart] Use a user provided default constructor for IDFUARTComponent (#19142) --- esphome/components/uart/uart_component_esp_idf.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 3b8603f2ac2..b591fbe9686 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -18,6 +18,8 @@ namespace esphome::uart { /// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent final : public UARTComponent, public Component { public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -102,7 +104,7 @@ class IDFUARTComponent final : public UARTComponent, public Component { Framing last_good_framing_{}; bool has_peek_{false}; - uint8_t peek_byte_; + uint8_t peek_byte_{0}; uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX From 4e0245da0db6d70e281033d08a922b6c4e5f31b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:13 -0500 Subject: [PATCH 245/759] [gpio] Use a user provided default constructor for GPIOBinarySensor (#19143) --- esphome/components/gpio/binary_sensor/gpio_binary_sensor.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 956443fab55..80636e29a6e 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -47,6 +47,9 @@ class GPIOBinarySensorStore { class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: + // User provided, not "= default": `new(p) GPIOBinarySensor()` would zero-fill .bss that is already zero. + GPIOBinarySensor() {} + // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. @@ -70,7 +73,7 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon void loop() override; protected: - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; GPIOBinarySensorStore store_; }; From 5b53c63eab74681c65575672754a9650f27fe085 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:18 -0500 Subject: [PATCH 246/759] [http_request] Use a user provided default constructor for HttpRequestIDF (#19175) --- esphome/components/http_request/http_request_idf.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 16a5b6a161c..1c062af81b1 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -30,6 +30,9 @@ class HttpContainerIDF : public HttpContainer { class HttpRequestIDF final : public HttpRequestComponent { public: + // User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero. + HttpRequestIDF() {} + void dump_config() override; void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } From ba679d91e413fafb30e518d164938798284fa9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:42 -0500 Subject: [PATCH 247/759] [restart] Use a user provided default constructor for RestartButton (#19168) --- esphome/components/restart/button/restart_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 974db0cec48..4baac6472c1 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} + void dump_config() override; protected: From 7eebe18e1a772f75ca2556be3cd655acdbd10be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:33:48 -0500 Subject: [PATCH 248/759] [ethernet_info] Use a user provided default constructor for IPAddressEthernetInfo (#19166) --- esphome/components/ethernet_info/ethernet_info_text_sensor.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 11002d51bad..c9fcda225f4 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -13,6 +13,9 @@ class IPAddressEthernetInfo final : public Component, public text_sensor::TextSensor, public ethernet::EthernetIPStateListener { public: + // User provided, not "= default": `new(p) IPAddressEthernetInfo()` would zero-fill .bss that is already zero. + IPAddressEthernetInfo() {} + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } From ac3ef0a02dc09a24315899a0e993eee4c3cde7f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:00 -0500 Subject: [PATCH 249/759] [preferences] Use a user provided default constructor for IntervalSyncer (#19164) --- esphome/components/preferences/syncer.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8a809672db4..5092c321479 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -9,6 +9,9 @@ namespace esphome::preferences { class IntervalSyncer final : public PollingComponent { public: + // User provided, not "= default": `new(p) IntervalSyncer()` would zero-fill .bss that is already zero. + IntervalSyncer() {} + // Remove before 2027.3.0 ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } From 569001d554bdb2d80ae5298b2dadb8982ad65efa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:34:07 -0500 Subject: [PATCH 250/759] [template] Use a user provided default constructor for TemplateSensor (#19125) --- esphome/components/template/sensor/template_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 825a2b4ffaa..68e22372679 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateSensor()` would zero-fill .bss that is already zero. + TemplateSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From 4714b77f45b8de4c66c224d67e4230cc56fa550c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:28 -0500 Subject: [PATCH 251/759] [core] Construct App without value initialization (#19109) --- esphome/core/application.h | 2 +- esphome/core/config.py | 5 +++-- tests/unit_tests/core/test_config.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac88..f1cf6fcca02 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -528,7 +528,7 @@ class Application { // 1-byte members (grouped together to minimize padding) uint8_t app_state_{0}; - bool name_add_mac_suffix_; + bool name_add_mac_suffix_{false}; bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; diff --git a/esphome/core/config.py b/esphome/core/config.py index 67a7b5210ee..8a4eb0fc379 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -717,9 +717,10 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) - # Construct App via placement new — see application.cpp for storage details + # Construct App via placement new — see application.cpp for storage details. + # No parens: `Application()` would zero-fill storage that is already zero. cg.add_global(cg.RawStatement("#include ")) - cg.add(cg.RawExpression("new (&App) Application()")) + cg.add(cg.RawExpression("new (&App) Application")) name = config[CONF_NAME] friendly_name = config[CONF_FRIENDLY_NAME] name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 8ab3ad5d153..07cff003cdd 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -175,6 +175,27 @@ async def test_core_area_recorded_at_config_load( assert CORE.area == expected_area +@pytest.mark.asyncio +async def test_app_is_default_initialized( + yaml_file: Callable[[str], Path], +) -> None: + """App is constructed with `new (&App) Application`, no parentheses. + + `Application()` would value-initialize and memset the whole object into + storage that is already zero.""" + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + raw_expressions = [c.args[0] for c in mock_cg.RawExpression.call_args_list] + assert "new (&App) Application" in raw_expressions + assert "new (&App) Application()" not in raw_expressions + + def test_config_load_without_area_clears_stale_core_area( yaml_file: Callable[[str], Path], ) -> None: From b7064f2bedc3800393605f1f4937b5af017863f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:31 -0500 Subject: [PATCH 252/759] [update] Initialize UpdateInfo::progress (#19121) --- esphome/components/update/update_entity.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f925d338ff7..96ba6dbd562 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -16,7 +16,7 @@ struct UpdateInfo { std::string firmware_url; std::string md5; bool has_progress{false}; - float progress; + float progress{0}; }; enum UpdateState : uint8_t { From da73140c9ca1336a4a9c9fcf56b702aa52e8df56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:36 -0500 Subject: [PATCH 253/759] [template] Use a user provided default constructor for TemplateTextSensor (#19126) --- esphome/components/template/text_sensor/template_text_sensor.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 0538a7ec211..8f03f78be4f 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -9,6 +9,8 @@ namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateTextSensor()` would zero-fill .bss that is already zero. + TemplateTextSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; From cbb66002635d3fbc5fa549b67e76382838f8ceac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:41 -0500 Subject: [PATCH 254/759] [template] Use a user provided default constructor for TemplateButton (#19124) --- esphome/components/template/button/template_button.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index f64a85eef07..bd07b2258c1 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -6,6 +6,9 @@ namespace esphome::template_ { class TemplateButton final : public button::Button { public: + // User provided, not "= default": `new(p) TemplateButton()` would zero-fill .bss that is already zero. + TemplateButton() {} + // Implements the abstract `press_action` but the `on_press` trigger already handles the press. void press_action() override{}; }; From a495fe56f5f3f00d6eb2d8430901115c701811f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:37:48 -0500 Subject: [PATCH 255/759] [template] Use a user provided default constructor for TemplateSelect (#19130) --- esphome/components/template/select/template_select.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 5da6d732bd4..1cc28a36d37 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -23,6 +23,8 @@ void update_lambda(BaseTemplateSelect *sel_comp, const optional &va template class TemplateSelect : public BaseTemplateSelect { public: + // User provided, not "= default": `new(p) TemplateSelect()` would zero-fill .bss that is already zero. + TemplateSelect() {} template void set_lambda(F &&f) { if constexpr (HAS_LAMBDA) { this->f_.set(std::forward(f)); From 925ddd6c09bcf5133092aa93d27f580c931278da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:00 -0500 Subject: [PATCH 256/759] [template] Use a user provided default constructor for TemplateEvent (#19127) --- esphome/components/template/event/template_event.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index fe83dc9f34b..3d2d9a9efe2 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -5,6 +5,10 @@ namespace esphome::template_ { -class TemplateEvent final : public Component, public event::Event {}; +class TemplateEvent final : public Component, public event::Event { + public: + // User provided, not "= default": `new(p) TemplateEvent()` would zero-fill .bss that is already zero. + TemplateEvent() {} +}; } // namespace esphome::template_ From 3180381a26d7fbf4a1423c9727250be85adee184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:07 -0500 Subject: [PATCH 257/759] [alarm_control_panel] Initialize the state members (#19120) --- .../components/alarm_control_panel/alarm_control_panel.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index e748b8621b9..aced89b7ffc 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -138,11 +138,11 @@ class AlarmControlPanel : public EntityBase { // in order to store last panel state in flash ESPPreferenceObject pref_; // current state - AlarmControlPanelState current_state_; + AlarmControlPanelState current_state_{ACP_STATE_DISARMED}; // the desired (or previous) state - AlarmControlPanelState desired_state_; + AlarmControlPanelState desired_state_{ACP_STATE_DISARMED}; // last time the state was updated - uint32_t last_update_; + uint32_t last_update_{0}; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - passes the new state to listeners From 7e750ec611a46377e85c793e21e8eb10792ab54e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:10 -0500 Subject: [PATCH 258/759] [text_sensor] Use a user provided default constructor (#19112) --- esphome/components/text_sensor/text_sensor.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 0e7364bf980..5041ebc4e08 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,7 +29,8 @@ class TextSensor : public EntityBase { public: std::string state; - TextSensor() = default; + // User provided, not "= default": `new(p) TextSensor()` would zero-fill .bss that is already zero. + TextSensor() {} ~TextSensor() = default; /// Getter-syntax for .state. From a5931ea20eab43e8dad6ffe733a179dcd516c3c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:21 -0500 Subject: [PATCH 259/759] [fan] Initialize Fan::restore_mode_ (#19118) --- esphome/components/fan/fan.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 106e6e74cd8..7e21971639e 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -183,7 +183,7 @@ class Fan : public EntityBase { LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; - FanRestoreMode restore_mode_; + FanRestoreMode restore_mode_{FanRestoreMode::NO_RESTORE}; private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). From 17c5daa24560607314c7dcd501a5dca4bb8f65e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:38:33 -0500 Subject: [PATCH 260/759] [ld2412] Drop the unused gate index from GateThresholdNumber (#19107) --- esphome/components/ld2412/number/__init__.py | 4 ++-- esphome/components/ld2412/number/gate_threshold_number.cpp | 2 -- esphome/components/ld2412/number/gate_threshold_number.h | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index 1a81c330adf..f27e241491d 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -109,14 +109,14 @@ async def to_code(config: ConfigType) -> None: for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) + n = cg.new_Pvariable(move_config[CONF_ID]) await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) await cg.register_parented(n, config[CONF_LD2412_ID]) cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) + n = cg.new_Pvariable(still_config[CONF_ID]) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 8d12bad1151..a0a525a8107 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -2,8 +2,6 @@ namespace esphome::ld2412 { -GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} - void GateThresholdNumber::control(float value) { this->publish_state(value); this->parent_->set_gate_threshold(); diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 918b6dfad1a..308da43a34d 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -7,10 +7,10 @@ namespace esphome::ld2412 { class GateThresholdNumber final : public number::Number, public Parented { public: - GateThresholdNumber(uint8_t gate); + // Not "= default": that makes new(p) T() zero-fill the object at every codegen site before the ctor runs. + GateThresholdNumber() {} protected: - uint8_t gate_; void control(float value) override; }; From fd011ec3379382978ea96131ec19e15d457b047c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:19 -0500 Subject: [PATCH 261/759] [esp8266_pwm] Use a user provided default constructor for ESP8266PWM (#19198) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 79c2e509848..87b76a392a6 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -11,6 +11,9 @@ namespace esphome::esp8266_pwm { class ESP8266PWM final : public output::FloatOutput, public Component { public: + // User provided, not "= default": `new(p) ESP8266PWM()` would zero-fill .bss that is already zero. + ESP8266PWM() {} + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency @@ -28,7 +31,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { protected: void write_state(float state) override; - InternalGPIOPin *pin_; + InternalGPIOPin *pin_{nullptr}; float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; From 0f01fa89b8661b828415c50d1be4e1c1a5b544cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:25 -0500 Subject: [PATCH 262/759] [api] Skip setters that pass the default port, reboot timeout and batch delay (#19227) --- esphome/components/api/__init__.py | 24 +++++++++---- esphome/components/api/api_server.h | 6 ++-- tests/component_tests/api/config/bare.yaml | 12 +++++++ tests/component_tests/api/config/custom.yaml | 15 ++++++++ .../component_tests/api/config/defaults.yaml | 15 ++++++++ .../api/test_default_setters.py | 35 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/api/config/bare.yaml create mode 100644 tests/component_tests/api/config/custom.yaml create mode 100644 tests/component_tests/api/config/defaults.yaml create mode 100644 tests/component_tests/api/test_default_setters.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 272b0786905..854bceecfad 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -136,6 +136,12 @@ CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +# Schema defaults that also match the C++ initializers in api_server.h; codegen +# skips the setter when the config equals them. +DEFAULT_PORT = 6053 +DEFAULT_REBOOT_TIMEOUT = "15min" +DEFAULT_BATCH_DELAY = "100ms" + def _register_provisioning_source(config: ConfigType) -> ConfigType: """Register the API as a provisioning source when encryption is enabled. @@ -292,7 +298,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(APIServer), - cv.Optional(CONF_PORT, default=6053): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, # Removed in 2026.1.0 - kept to provide helpful error message cv.Optional(CONF_PASSWORD): cv.invalid( "The 'password' option has been removed in ESPHome 2026.1.0.\n" @@ -305,14 +311,14 @@ CONFIG_SCHEMA = cv.All( "Or visit https://esphome.io/components/api/#configuration-variables" ), cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Exclusive( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, - cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( + cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), @@ -462,9 +468,15 @@ async def to_code(config: ConfigType) -> None: # Request a log listener slot for API log streaming request_log_listener() - cg.add(var.set_port(config[CONF_PORT])) - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + # Skip the setters when the config matches the C++ initializers (DEFAULT_*). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(var.set_port(port)) + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): + cg.add(var.set_batch_delay(batch_delay)) if CONF_LISTEN_BACKLOG in config: cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11a..e5a22dcef82 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -314,7 +314,7 @@ class APIServer final : public Component, #endif // 4-byte aligned types - uint32_t reboot_timeout_{300000}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. @@ -351,8 +351,8 @@ class APIServer final : public Component, #endif // Group smaller types together - uint16_t port_{6053}; - uint16_t batch_delay_{100}; + uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py + uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py // Connection limits - these defaults will be overridden by config values // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; diff --git a/tests/component_tests/api/config/bare.yaml b/tests/component_tests/api/config/bare.yaml new file mode 100644 index 00000000000..be5c73f18bc --- /dev/null +++ b/tests/component_tests/api/config/bare.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: diff --git a/tests/component_tests/api/config/custom.yaml b/tests/component_tests/api/config/custom.yaml new file mode 100644 index 00000000000..cdf4038d5d5 --- /dev/null +++ b/tests/component_tests/api/config/custom.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6054 + reboot_timeout: 0s + batch_delay: 0ms diff --git a/tests/component_tests/api/config/defaults.yaml b/tests/component_tests/api/config/defaults.yaml new file mode 100644 index 00000000000..b20fd9b884c --- /dev/null +++ b/tests/component_tests/api/config/defaults.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +api: + port: 6053 + reboot_timeout: 15min + batch_delay: 100ms diff --git a/tests/component_tests/api/test_default_setters.py b/tests/component_tests/api/test_default_setters.py new file mode 100644 index 00000000000..32d35cacb7c --- /dev/null +++ b/tests/component_tests/api/test_default_setters.py @@ -0,0 +1,35 @@ +"""Tests that the api component only emits setters for non default values.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +def test_default_values_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers. + + Both the schema defaults and the same values written explicitly take the skip path. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert "api_apiserver_id->set_port(" not in main_cpp + assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp + assert "api_apiserver_id->set_batch_delay(" not in main_cpp + + +def test_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Non default values still reach the C++ setters.""" + main_cpp = generate_main(component_config_path("custom.yaml")) + + assert "api_apiserver_id->set_port(6054);" in main_cpp + assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp + assert "api_apiserver_id->set_batch_delay(0);" in main_cpp From 0d5683525e0f4b12367ff5a2a09a0b4df7b66786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:29 -0500 Subject: [PATCH 263/759] [core] Use a user provided default constructor for DelayAction (#19137) --- esphome/core/base_automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 276b8aa9728..999b38bd5cf 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -180,7 +180,9 @@ class ProjectUpdateTrigger : public Trigger, public Component { template class DelayAction : public Action { public: - explicit DelayAction() = default; + // User provided, not "= default": `new(p) DelayAction()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr explicit DelayAction() noexcept {} TEMPLATABLE_VALUE(uint32_t, delay) From 0d4c9f3243b0e6e7ae9d026d641ccd0b8ffa9a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:32 -0500 Subject: [PATCH 264/759] [core] Use a user provided default constructor for Automation (#19197) --- esphome/core/automation.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ea522a4d2da..5f010521dce 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -608,7 +608,9 @@ template class ActionList { template class Automation { public: /// Default constructor for use with TriggerForwarder (no Trigger object needed). - Automation() = default; + // User provided, not "= default": `new(p) Automation()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr Automation() noexcept {} explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } From dd40678ad70be06405070115a7e0855cb66bf278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:37 -0500 Subject: [PATCH 265/759] [pca9554] Use a user provided default constructor for PCA9554GPIOPin (#19208) --- esphome/components/pca9554/pca9554.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 05e945d1763..cc95f147ac5 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -55,6 +55,9 @@ class PCA9554Component final : public Component, /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. class PCA9554GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. + PCA9554GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -69,10 +72,10 @@ class PCA9554GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCA9554Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCA9554Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pca9554 From 237d87880fe4709e00c2ca76182fc184d8729985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 03:42:42 -0500 Subject: [PATCH 266/759] [pcf8574] Use a user provided default constructor for PCF8574GPIOPin (#19207) --- esphome/components/pcf8574/pcf8574.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index e8f78bae506..9879d6a47eb 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -51,6 +51,9 @@ class PCF8574Component final : public Component, /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. class PCF8574GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. + PCF8574GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -65,10 +68,10 @@ class PCF8574GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCF8574Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCF8574Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pcf8574 From 4c7aee1a77cbe30249a0132f19f0a6e9c3db97a5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:49:56 +1200 Subject: [PATCH 267/759] [improv_ble] Rename from esp32_improv and decouple from ESP32 (#19264) --- CODEOWNERS | 2 +- esphome/component_aliases.py | 1 + .../components/esp32_ble_server/__init__.py | 2 +- .../components/esp32_ble_server/ble_server.h | 2 +- esphome/components/improv_base/__init__.py | 6 +- .../components/improv_base/improv_base.cpp | 2 +- esphome/components/improv_base/improv_base.h | 6 +- .../{esp32_improv => improv_ble}/__init__.py | 90 +++++++++++++------ .../{esp32_improv => improv_ble}/automation.h | 38 ++++---- .../improv_ble_component.cpp} | 69 +++++++------- .../improv_ble_component.h} | 24 ++--- esphome/components/improv_serial/__init__.py | 2 +- .../improv_serial/improv_serial_component.cpp | 2 +- .../improv_serial/improv_serial_component.h | 2 +- esphome/components/wifi/__init__.py | 2 +- esphome/components/wifi/wifi_component.cpp | 44 ++++----- esphome/components/wifi/wifi_component.h | 2 +- esphome/core/defines.h | 7 +- platformio.ini | 2 +- .../esp32_ble_server/config/improv_only.yaml | 2 +- .../esp32_ble_server/test_esp32_ble_server.py | 2 +- tests/component_tests/improv_ble/__init__.py | 0 .../improv_ble/config/automations.yaml | 31 +++++++ .../improv_ble/config/esp32.yaml | 12 +++ .../improv_ble/config/esp8266.yaml | 10 +++ .../improv_ble/config/legacy_key.yaml | 12 +++ .../improv_ble/test_improv_ble.py | 59 ++++++++++++ .../improv_base/rpc_response_builder_test.cpp | 2 +- .../{esp32_improv => improv_ble}/common.yaml | 2 +- .../test.esp32-c3-idf.yaml | 0 .../test.esp32-idf.yaml | 0 .../improv_serial/common-uart0.yaml | 2 +- .../provisioning/test.esp32-idf.yaml | 4 +- 33 files changed, 303 insertions(+), 140 deletions(-) rename esphome/components/{esp32_improv => improv_ble}/__init__.py (63%) rename esphome/components/{esp32_improv => improv_ble}/automation.h (55%) rename esphome/components/{esp32_improv/esp32_improv_component.cpp => improv_ble/improv_ble_component.cpp} (91%) rename esphome/components/{esp32_improv/esp32_improv_component.h => improv_ble/improv_ble_component.h} (86%) create mode 100644 tests/component_tests/improv_ble/__init__.py create mode 100644 tests/component_tests/improv_ble/config/automations.yaml create mode 100644 tests/component_tests/improv_ble/config/esp32.yaml create mode 100644 tests/component_tests/improv_ble/config/esp8266.yaml create mode 100644 tests/component_tests/improv_ble/config/legacy_key.yaml create mode 100644 tests/component_tests/improv_ble/test_improv_ble.py rename tests/components/{esp32_improv => improv_ble}/common.yaml (96%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-c3-idf.yaml (100%) rename tests/components/{esp32_improv => improv_ble}/test.esp32-idf.yaml (100%) diff --git a/CODEOWNERS b/CODEOWNERS index 044d0051193..9b34d523d20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -182,7 +182,6 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 -esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -268,6 +267,7 @@ esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core +esphome/components/improv_ble/* @jesserockz esphome/components/improv_serial/* @esphome/core esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py index e701bd98d4e..53a34d1e15f 100644 --- a/esphome/component_aliases.py +++ b/esphome/component_aliases.py @@ -6,5 +6,6 @@ See the component-alias section of esphome/loader.py. # alias -> (canonical component, removal version or None) COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "esp32_improv": ("improv_ble", "2027.4.0"), "rp2040": ("rp2", "2027.7.0"), } diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 118ae06e420..924d11db2b1 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -597,7 +597,7 @@ async def to_code(config): cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) # Only advertise for the server itself when the configuration gives clients something to - # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. cg.add( var.set_advertising_required( diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7869c73cc53..e469b60e088 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -40,7 +40,7 @@ class BLEServer final : public Component, public Parented { /** Whether this server needs the device to advertise so clients can find and connect to it. * - * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * False for a server that only hosts services created at runtime (e.g. improv_ble), which * request advertising themselves for as long as they need it. */ void set_advertising_required(bool required) { this->advertising_required_ = required; } diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 412d143a486..9b57b6561f9 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -38,9 +38,11 @@ def _process_next_url(url: str) -> str: return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: +async def setup_improv_core(var: MockObj, config: ConfigType) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) - cg.add_define(f"USE_{component.upper()}_NEXT_URL") + # One define for all transports: next_url_ is per object, so a transport + # configured without next_url: calls add_next_url_ and appends nothing. + cg.add_define("USE_IMPROV_NEXT_URL") cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 1babeb5b5a2..6745f8064b1 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -8,7 +8,7 @@ namespace esphome::improv_base { -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL static const char *const TAG = "improv_base"; static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 352bb75d5fc..97801302d4f 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL #include #endif @@ -11,12 +11,12 @@ namespace esphome::improv_base { class ImprovBase { public: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL void set_next_url(const char *next_url) { this->next_url_ = next_url; } #endif protected: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); /// Append the formatted next_url to the RPC response, warning if it does not fit. diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/improv_ble/__init__.py similarity index 63% rename from esphome/components/esp32_improv/__init__.py rename to esphome/components/improv_ble/__init__.py index 32eb1660142..72ac5866286 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,41 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, improv_base, output -from esphome.components.esp32_ble import BTLoggers +from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import ( + CONF_ID, + CONF_ON_START, + CONF_ON_STATE, + CONF_TRIGGER_ID, + PLATFORM_ESP32, +) +from esphome.core import CORE from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_server", "improv_base"] +# The BLE GATT server component that hosts the Improv service, per target +# platform. improv_ble itself is platform neutral; supporting another chip +# means adding its BLE server component here and the matching backend in +# improv_ble_component.cpp. Doubles as the platform gate below, so an +# unsupported chip is rejected in validation rather than at link time. +BLE_SERVER_BACKENDS: dict[str, str] = { + PLATFORM_ESP32: "esp32_ble_server", +} + + +def AUTO_LOAD() -> list[str]: + auto_load = ["improv_base"] + if backend := BLE_SERVER_BACKENDS.get(CORE.target_platform): + auto_load.append(backend) + return auto_load + + CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["wifi", "esp32"] +DEPENDENCIES = ["wifi"] + +# Legacy top-level YAML key that routes here; esphome/loader.py and +# esphome/config.py handle the warning and the key rename. +ALIASES = ["esp32_improv"] +ALIAS_REMOVAL_VERSION = "2027.4.0" CONF_AUTHORIZED_DURATION = "authorized_duration" CONF_AUTHORIZER = "authorizer" @@ -29,29 +56,29 @@ improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") -esp32_improv_ns = cg.esphome_ns.namespace("esp32_improv") -ESP32ImprovComponent = esp32_improv_ns.class_("ESP32ImprovComponent", cg.Component) -ESP32ImprovProvisionedTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisionedTrigger", automation.Trigger.template() +improv_ble_ns = cg.esphome_ns.namespace("improv_ble") +ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) +ImprovBLEProvisionedTrigger = improv_ble_ns.class_( + "ImprovBLEProvisionedTrigger", automation.Trigger.template() ) -ESP32ImprovProvisioningTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisioningTrigger", automation.Trigger.template() +ImprovBLEProvisioningTrigger = improv_ble_ns.class_( + "ImprovBLEProvisioningTrigger", automation.Trigger.template() ) -ESP32ImprovStartTrigger = esp32_improv_ns.class_( - "ESP32ImprovStartTrigger", automation.Trigger.template() +ImprovBLEStartTrigger = improv_ble_ns.class_( + "ImprovBLEStartTrigger", automation.Trigger.template() ) -ESP32ImprovStateTrigger = esp32_improv_ns.class_( - "ESP32ImprovStateTrigger", automation.Trigger.template() +ImprovBLEStateTrigger = improv_ble_ns.class_( + "ImprovBLEStateTrigger", automation.Trigger.template() ) -ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( - "ESP32ImprovStoppedTrigger", automation.Trigger.template() +ImprovBLEStoppedTrigger = improv_ble_ns.class_( + "ImprovBLEStoppedTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.GenerateID(): cv.declare_id(ImprovBLEComponent), cv.Required(CONF_AUTHORIZER): cv.Any( cv.none, cv.use_id(binary_sensor.BinarySensor) ), @@ -68,55 +95,60 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger + ImprovBLEProvisionedTrigger ), } ), cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger + ImprovBLEProvisioningTrigger ), } ), cv.Optional(CONF_ON_START): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStartTrigger + ImprovBLEStartTrigger ), } ), cv.Optional(CONF_ON_STATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStateTrigger + ImprovBLEStateTrigger ), } ), cv.Optional(CONF_ON_STOP): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger + ImprovBLEStoppedTrigger ), } ), } ) .extend(improv_base.IMPROV_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on(list(BLE_SERVER_BACKENDS)), ) async def to_code(config: ConfigType) -> None: + # ESP32 backend setup: the platform gate above means this is the only backend + # that can reach to_code. Make it conditional when a second one is added. + from esphome.components import esp32_ble + # Register the loggers this component needs - esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + esp32_ble.register_bt_logger(esp32_ble.BTLoggers.GATT, esp32_ble.BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - cg.add_define("USE_IMPROV") + cg.add_define("USE_IMPROV_BLE") - await improv_base.setup_improv_core(var, config, "esp32_improv") + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) @@ -155,4 +187,4 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_ESP32_IMPROV_STATE_CALLBACK") + cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/improv_ble/automation.h similarity index 55% rename from esphome/components/esp32_improv/automation.h rename to esphome/components/improv_ble/automation.h index b3b61f47785..223a1292384 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/improv_ble/automation.h @@ -1,17 +1,17 @@ #pragma once #ifdef USE_ESP32 -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK -#include "esp32_improv_component.h" +#ifdef USE_IMPROV_BLE_STATE_CALLBACK +#include "improv_ble_component.h" #include "esphome/core/automation.h" #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { -class ESP32ImprovProvisionedTrigger final : public Trigger<> { +class ImprovBLEProvisionedTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { this->trigger(); @@ -20,12 +20,12 @@ class ESP32ImprovProvisionedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovProvisioningTrigger final : public Trigger<> { +class ImprovBLEProvisioningTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { this->trigger(); @@ -34,12 +34,12 @@ class ESP32ImprovProvisioningTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStartTrigger final : public Trigger<> { +class ImprovBLEStartTrigger final : public Trigger<> { public: - explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && !this->parent_->is_failed()) { @@ -49,12 +49,12 @@ class ESP32ImprovStartTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStateTrigger final : public Trigger { public: - explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (!this->parent_->is_failed()) { this->trigger(state, error); @@ -63,12 +63,12 @@ class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStoppedTrigger final : public Trigger<> { public: - explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { this->trigger(); @@ -77,10 +77,10 @@ class ESP32ImprovStoppedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp similarity index 91% rename from esphome/components/esp32_improv/esp32_improv_component.cpp rename to esphome/components/improv_ble/improv_ble_component.cpp index 9ec6eb7bab6..bbc1589abf0 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -1,10 +1,7 @@ -#include "esp32_improv_component.h" +#include "improv_ble_component.h" #include -#include "esphome/components/bytebuffer/bytebuffer.h" -#include "esphome/components/esp32_ble/ble.h" -#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,11 +12,15 @@ #ifdef USE_ESP32 -namespace esphome::esp32_improv { +#include "esphome/components/bytebuffer/bytebuffer.h" +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/components/esp32_ble_server/ble_2902.h" + +namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "esp32_improv.component"; +static const char *const TAG = "improv_ble.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -38,9 +39,9 @@ static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 -ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } +ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } -void ESP32ImprovComponent::setup() { +void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR if (this->authorizer_ != nullptr) { this->authorizer_->add_on_state_callback([this](bool state) { @@ -66,7 +67,7 @@ void ESP32ImprovComponent::setup() { this->disable_loop(); } -void ESP32ImprovComponent::setup_characteristics() { +void ImprovBLEComponent::setup_characteristics() { this->status_ = this->service_->create_characteristic( improv::STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLEDescriptor *status_descriptor = new BLE2902(); @@ -104,11 +105,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->setup_complete_ = true; } -void ESP32ImprovComponent::loop() { +void ImprovBLEComponent::loop() { if (!global_ble_server->is_running()) { if (this->state_ != improv::STATE_STOPPED) { this->state_ = improv::STATE_STOPPED; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } @@ -200,7 +201,7 @@ void ESP32ImprovComponent::loop() { } } -void ESP32ImprovComponent::set_status_indicator_state_(bool state) { +void ImprovBLEComponent::set_status_indicator_state_(bool state) { #ifdef USE_OUTPUT if (this->status_indicator_ == nullptr) return; @@ -216,7 +217,7 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -const char *ESP32ImprovComponent::state_to_string_(improv::State state) { +const char *ImprovBLEComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: return "STOPPED"; @@ -234,7 +235,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { } #endif -bool ESP32ImprovComponent::check_identify_() { +bool ImprovBLEComponent::check_identify_() { uint32_t now = millis(); bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_; @@ -246,7 +247,7 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { +void ImprovBLEComponent::set_state_(improv::State state, bool update_advertising) { // Skip if state hasn't changed if (this->state_ == state) { return; @@ -274,12 +275,12 @@ void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertisi // Advertise the new state via service data this->advertise_service_data_(); } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } -void ESP32ImprovComponent::set_error_(improv::Error error) { +void ImprovBLEComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } @@ -295,14 +296,14 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::span response) { +void ImprovBLEComponent::send_response_(std::span response) { // The BLE characteristic owns its value, so one exact-size copy is required here this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } -void ESP32ImprovComponent::start() { +void ImprovBLEComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; @@ -320,7 +321,7 @@ void ESP32ImprovComponent::start() { this->enable_loop(); } -void ESP32ImprovComponent::stop() { +void ImprovBLEComponent::stop() { this->should_start_ = false; // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing @@ -335,10 +336,10 @@ void ESP32ImprovComponent::stop() { }); } -float ESP32ImprovComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } -void ESP32ImprovComponent::dump_config() { - ESP_LOGCONFIG(TAG, "ESP32 Improv:"); +void ImprovBLEComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); #endif @@ -347,7 +348,7 @@ void ESP32ImprovComponent::dump_config() { #endif } -void ESP32ImprovComponent::process_incoming_data_() { +void ImprovBLEComponent::process_incoming_data_() { if (this->incoming_data_.size() < 3) return; uint8_t length = this->incoming_data_[1]; @@ -422,7 +423,7 @@ void ESP32ImprovComponent::process_incoming_data_() { } } -void ESP32ImprovComponent::on_wifi_connect_timeout_() { +void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR @@ -433,7 +434,7 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } -void ESP32ImprovComponent::check_wifi_connection_() { +void ImprovBLEComponent::check_wifi_connection_() { if (!wifi::global_wifi_component->is_connected()) { return; } @@ -447,7 +448,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { std::array buf; improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); -#ifdef USE_ESP32_IMPROV_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif @@ -480,7 +481,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->stop(); } -void ESP32ImprovComponent::advertise_service_data_() { +void ImprovBLEComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR service_data[1] = IMPROV_PROTOCOL_ID_2; // IM @@ -499,7 +500,7 @@ void ESP32ImprovComponent::advertise_service_data_() { esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); } -void ESP32ImprovComponent::update_advertising_type_() { +void ImprovBLEComponent::update_advertising_type_() { uint32_t now = App.get_loop_component_start_time(); // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data @@ -524,21 +525,21 @@ void ESP32ImprovComponent::update_advertising_type_() { } } -void ESP32ImprovComponent::request_advertising_() { +void ImprovBLEComponent::request_advertising_() { if (this->advertising_requested_) return; this->advertising_requested_ = true; esp32_ble::global_ble->advertising_start(); } -void ESP32ImprovComponent::release_advertising_() { +void ImprovBLEComponent::release_advertising_() { if (!this->advertising_requested_) return; this->advertising_requested_ = false; esp32_ble::global_ble->advertising_stop(); } -improv::State ESP32ImprovComponent::get_initial_state_() const { +improv::State ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; @@ -548,8 +549,8 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { #endif } -ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +ImprovBLEComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/improv_ble/improv_ble_component.h similarity index 86% rename from esphome/components/esp32_improv/esp32_improv_component.h rename to esphome/components/improv_ble/improv_ble_component.h index a40d60552a8..2552bed69b5 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -5,12 +5,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -#include "esphome/components/esp32_ble_server/ble_characteristic.h" -#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK #include "esphome/core/automation.h" #endif @@ -25,17 +23,23 @@ #include #include +// ESP-IDF is currently the only target platform with a BLE GATT server, so it is +// the only backend this component has. The Python side keeps the platform table +// (BLE_SERVER_BACKENDS in __init__.py); a second backend adds another arm here. #ifdef USE_ESP32 +#include "esphome/components/esp32_ble_server/ble_characteristic.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { using namespace esp32_ble_server; -class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { +class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { public: - ESP32ImprovComponent(); + ImprovBLEComponent(); void dump_config() override; void loop() override; void setup() override; @@ -47,7 +51,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool should_start() const { return this->should_start_; } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); } @@ -97,7 +101,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB improv::State state_{improv::STATE_STOPPED}; improv::Error error_state_{improv::ERROR_NONE}; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif @@ -125,8 +129,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern ESP32ImprovComponent *global_improv_component; +extern ImprovBLEComponent *global_improv_component; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a34e2ab7931..0231791e9b3 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -70,7 +70,7 @@ FINAL_VALIDATE_SCHEMA = validate_transport async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await improv_base.setup_improv_core(var, config, "improv_serial") + await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: cg.add(var.set_uart(await cg.get_variable(uart_id))) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ffa7b79d9bf..3827fb6ed45 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -208,7 +208,7 @@ void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &buil void ImprovSerialComponent::send_settings_response_(improv::Command command) { std::array buf; improv::RpcResponseBuilder builder(buf, command); -#ifdef USE_IMPROV_SERIAL_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 68cdd752149..c7d89c76d6b 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -55,7 +55,7 @@ static const uint8_t IMPROV_SERIAL_VERSION = 1; #ifdef USE_WIFI // Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while // switching networks on an already-connected device (disconnect + reconnect) can legitimately -// take longer; 90 s matches esp32_improv's default wifi_timeout. +// take longer; 90 s matches improv_ble's default wifi_timeout. static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; #endif diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index a1a3436d470..c22d49e6654 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -355,7 +355,7 @@ def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() - has_improv = "esp32_improv" in full_config + has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ba36143945..125139ad163 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,8 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_IMPROV -#include "esphome/components/esp32_improv/esp32_improv_component.h" +#ifdef USE_IMPROV_BLE +#include "esphome/components/improv_ble/improv_ble_component.h" #endif #ifdef USE_IMPROV_SERIAL @@ -226,7 +226,7 @@ bool CompactString::operator==(const StringRef &other) const { /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ -/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ When captive_portal or improv_ble is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ @@ -478,9 +478,9 @@ bool WiFiComponent::needs_full_scan_results_() const { } #endif -#ifdef USE_IMPROV +#ifdef USE_IMPROV_BLE // BLE improv also needs results during provisioning - if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { + if (improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active()) { return true; } #endif @@ -746,10 +746,10 @@ void WiFiComponent::start() { #endif #endif // USE_WIFI_AP } -#ifdef USE_IMPROV - if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { +#ifdef USE_IMPROV_BLE + if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); @@ -805,7 +805,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -894,12 +894,12 @@ void WiFiComponent::loop() { } #endif // USE_WIFI_AP -#ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && - !esp32_improv::global_improv_component->should_start()) { - if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { +#ifdef USE_IMPROV_BLE + if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && + !improv_ble::global_improv_component->should_start()) { + if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } } @@ -1644,9 +1644,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } -#ifdef USE_IMPROV - if (this->is_esp32_improv_active_()) { - esp32_improv::global_improv_component->stop(); +#ifdef USE_IMPROV_BLE + if (this->is_improv_ble_active_()) { + improv_ble::global_improv_component->stop(); } #endif @@ -1878,7 +1878,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_esp32_improv_active_()) { + } else if (this->is_improv_ble_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1969,7 +1969,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2228,9 +2228,9 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } -bool WiFiComponent::is_esp32_improv_active_() { -#ifdef USE_IMPROV - return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); +bool WiFiComponent::is_improv_ble_active_() { +#ifdef USE_IMPROV_BLE + return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); #else return false; #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a0983545fbd..67913796499 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -797,7 +797,7 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); - bool is_esp32_improv_active_(); + bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b36d39bbefa..b2b5267b112 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,7 +70,6 @@ #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE -#define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN @@ -83,6 +82,7 @@ #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE +#define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF #define USE_JSON @@ -266,7 +266,7 @@ #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL -#define USE_IMPROV_SERIAL_NEXT_URL +#define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE #define USE_SHA256 @@ -392,8 +392,7 @@ #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C -#define USE_IMPROV -#define USE_ESP32_IMPROV_NEXT_URL +#define USE_IMPROV_BLE #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN diff --git a/platformio.ini b/platformio.ini index 0e334ac5b4c..722109adec4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,7 +46,7 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea esphome/noise-c@0.1.30 ; noise (api, ota) - improv/Improv@1.2.7 ; improv_serial / esp32_improv + improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image diff --git a/tests/component_tests/esp32_ble_server/config/improv_only.yaml b/tests/component_tests/esp32_ble_server/config/improv_only.yaml index 8a5c3ba6383..4239d24b0fe 100644 --- a/tests/component_tests/esp32_ble_server/config/improv_only.yaml +++ b/tests/component_tests/esp32_ble_server/config/improv_only.yaml @@ -9,5 +9,5 @@ wifi: password: password1 # esp32_ble_server is only auto-loaded here, so it has no services of its own. -esp32_improv: +improv_ble: authorizer: none diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 4b7ab79a81c..21a12d9cf2e 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None: @pytest.mark.parametrize( ("config_file", "required"), [ - # Auto-loaded by esp32_improv only: nothing to find until Improv asks for it + # Auto-loaded by improv_ble only: nothing to find until Improv asks for it ("improv_only.yaml", False), # The configuration defines a service clients are meant to connect to ("own_service.yaml", True), diff --git a/tests/component_tests/improv_ble/__init__.py b/tests/component_tests/improv_ble/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/improv_ble/config/automations.yaml b/tests/component_tests/improv_ble/config/automations.yaml new file mode 100644 index 00000000000..d5d97f5cbf3 --- /dev/null +++ b/tests/component_tests/improv_ble/config/automations.yaml @@ -0,0 +1,31 @@ +esphome: + name: improv-ble-automations +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button +output: + - platform: gpio + pin: 2 + id: built_in_led +improv_ble: + authorizer: io0_button + status_indicator: built_in_led + on_provisioned: + - logger.log: provisioned + on_provisioning: + - logger.log: provisioning + on_start: + - logger.log: start + on_state: + - logger.log: state + on_stop: + - logger.log: stop diff --git a/tests/component_tests/improv_ble/config/esp32.yaml b/tests/component_tests/improv_ble/config/esp32.yaml new file mode 100644 index 00000000000..ed55ef358ae --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp32.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-esp32 +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/esp8266.yaml b/tests/component_tests/improv_ble/config/esp8266.yaml new file mode 100644 index 00000000000..d32defd6f33 --- /dev/null +++ b/tests/component_tests/improv_ble/config/esp8266.yaml @@ -0,0 +1,10 @@ +esphome: + name: improv-ble-esp8266 +esp8266: + board: nodemcuv2 +logger: +wifi: + ssid: MySSID + password: password1 +improv_ble: + authorizer: none diff --git a/tests/component_tests/improv_ble/config/legacy_key.yaml b/tests/component_tests/improv_ble/config/legacy_key.yaml new file mode 100644 index 00000000000..9491203ca90 --- /dev/null +++ b/tests/component_tests/improv_ble/config/legacy_key.yaml @@ -0,0 +1,12 @@ +esphome: + name: improv-ble-legacy-key +esp32: + variant: esp32 + framework: + type: esp-idf +logger: +wifi: + ssid: MySSID + password: password1 +esp32_improv: + authorizer: none diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py new file mode 100644 index 00000000000..02293bdb23b --- /dev/null +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -0,0 +1,59 @@ +"""improv_ble is platform neutral; only its BLE server backends are not. + +Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that +keeps pre-rename configurations working. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_esp32_generates_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("esp32.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + + +def test_legacy_key_routes_to_improv_ble( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + main_cpp = generate_main(component_config_path("legacy_key.yaml")) + assert "improv_ble::ImprovBLEComponent" in main_cpp + assert "'esp32_improv:' top-level key is deprecated" in caplog.text + + +def test_platform_without_ble_server_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + # AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so + # the platform gate in CONFIG_SCHEMA is what has to reject the config. + CORE.config_path = component_config_path("esp8266.yaml") + assert read_config({}) is None + assert "only available on" in capsys.readouterr().out + + +def test_automations_emit_renamed_triggers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("automations.yaml")) + for trigger in ( + "ImprovBLEProvisionedTrigger", + "ImprovBLEProvisioningTrigger", + "ImprovBLEStartTrigger", + "ImprovBLEStateTrigger", + "ImprovBLEStoppedTrigger", + ): + assert f"improv_ble::{trigger}" in main_cpp + assert "set_authorizer" in main_cpp + assert "set_status_indicator" in main_cpp diff --git a/tests/components/improv_base/rpc_response_builder_test.cpp b/tests/components/improv_base/rpc_response_builder_test.cpp index d9d0ad90d88..f7f0eda38c4 100644 --- a/tests/components/improv_base/rpc_response_builder_test.cpp +++ b/tests/components/improv_base/rpc_response_builder_test.cpp @@ -52,7 +52,7 @@ TEST(RpcResponseBuilder, GoldenBytes) { (std::vector{0x04, 0x03, 0x02, 'a', 'b', 0xCC})); } -// esp32_improv calls finish() and build_rpc_response() with no checksum flag, +// improv_ble calls finish() and build_rpc_response() with no checksum flag, // so the two defaults must agree TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) { const std::vector urls = {"https://example.com"}; diff --git a/tests/components/esp32_improv/common.yaml b/tests/components/improv_ble/common.yaml similarity index 96% rename from tests/components/esp32_improv/common.yaml rename to tests/components/improv_ble/common.yaml index 7dc2f7b6c73..7605cd6e657 100644 --- a/tests/components/esp32_improv/common.yaml +++ b/tests/components/improv_ble/common.yaml @@ -12,7 +12,7 @@ output: pin: 2 id: built_in_led -esp32_improv: +improv_ble: authorizer: io0_button authorized_duration: 1min status_indicator: built_in_led diff --git a/tests/components/esp32_improv/test.esp32-c3-idf.yaml b/tests/components/improv_ble/test.esp32-c3-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-c3-idf.yaml rename to tests/components/improv_ble/test.esp32-c3-idf.yaml diff --git a/tests/components/esp32_improv/test.esp32-idf.yaml b/tests/components/improv_ble/test.esp32-idf.yaml similarity index 100% rename from tests/components/esp32_improv/test.esp32-idf.yaml rename to tests/components/improv_ble/test.esp32-idf.yaml diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common-uart0.yaml index 45bf1e5c330..3710cb3bb53 100644 --- a/tests/components/improv_serial/common-uart0.yaml +++ b/tests/components/improv_serial/common-uart0.yaml @@ -5,6 +5,6 @@ wifi: logger: hardware_uart: UART0 -# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_ +# next_url compiles the USE_IMPROV_NEXT_URL branch and add_next_url_ improv_serial: next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}} diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml index baa3aa8f683..4a34539002e 100644 --- a/tests/components/provisioning/test.esp32-idf.yaml +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -1,6 +1,6 @@ # Exercises the provisioning window: api registers as a provisioning source # (encryption enabled, no key), the on_timeout automation, and the wifi (AP + -# captive portal) and esp32_improv cross-component guards. improv_serial is +# captive portal) and improv_ble cross-component guards. improv_serial is # intentionally NOT gated. provisioning: timeout: 1min @@ -26,5 +26,5 @@ binary_sensor: pin: 0 id: io0_button -esp32_improv: +improv_ble: authorizer: io0_button From 3a9bb7e5bc6c4c22bf08f46ebccfb400b4287f0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 08:41:48 -0500 Subject: [PATCH 268/759] [http_request] Keep the update manifest URL as a pointer to the literal (#19211) --- .../update/http_request_update.cpp | 23 ++++++++++++------- .../http_request/update/http_request_update.h | 6 +++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 57dc86d55cf..6a74c00e8e5 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -1,5 +1,7 @@ #include "http_request_update.h" +#include + #include "esphome/core/application.h" #include "esphome/core/version.h" @@ -94,7 +96,7 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -174,21 +176,26 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - std::string path = info->firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - info->firmware_url = domain + path; + const char *source = this_update->source_url_; + const size_t source_len = strlen(source); + size_t prefix_len; + if (info->firmware_url[0] == '/') { + // scheme and host, up to the first slash after "https://" + const char *host_end = source_len > 8 ? strchr(source + 8, '/') : nullptr; + prefix_len = host_end != nullptr ? host_end - source : source_len; } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - info->firmware_url = domain + path; + // directory of the manifest, up to and including its last slash + const char *dir_end = strrchr(source, '/'); + prefix_len = dir_end != nullptr ? dir_end - source + 1 : 0; } + info->firmware_url.insert(0, source, prefix_len); } #ifdef ESPHOME_PROJECT_VERSION diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index be9fbf72bfd..05a741b6cd9 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -21,7 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const std::string &source_url) { this->source_url_ = source_url; } + void set_source_url(const char *source_url) { this->source_url_ = source_url; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -33,13 +33,15 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; - std::string source_url_; static void update_task(void *params); #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif uint8_t initial_check_remaining_{0}; + + private: + const char *source_url_{nullptr}; // literal from codegen }; } // namespace esphome::http_request From 544f8ae77175e086eab391cb9d958895506c8788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:14:52 -0500 Subject: [PATCH 269/759] [http_request] Take the URL and method as C strings (#19215) --- .../components/http_request/http_request.h | 64 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 19 +++--- .../http_request/http_request_arduino.h | 2 +- .../http_request/http_request_host.cpp | 27 ++++---- .../http_request/http_request_host.h | 2 +- .../http_request/http_request_idf.cpp | 21 +++--- .../http_request/http_request_idf.h | 2 +- 7 files changed, 85 insertions(+), 52 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 4471dffdc2b..71668b8556f 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -331,27 +331,46 @@ class HttpRequestComponent : public Component { void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } - std::shared_ptr get(const std::string &url) { - return this->start(url, "GET", "", std::vector
{}); - } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + std::shared_ptr get(const char *url) { return this->start(url, "GET", "", std::vector
{}); } + std::shared_ptr get(const char *url, const std::vector
&request_headers) { return this->start(url, "GET", "", request_headers); } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + std::shared_ptr get(const char *url, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->start(url, "GET", "", request_headers, lower_case_collect_headers); } - std::shared_ptr post(const std::string &url, const std::string &body) { + std::shared_ptr post(const char *url, const std::string &body) { return this->start(url, "POST", body, std::vector
{}); } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers) { + return this->start(url, "POST", body, request_headers); + } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + } + + std::shared_ptr get(const std::string &url) { return this->get(url.c_str()); } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + return this->get(url.c_str(), request_headers); + } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->get(url.c_str(), request_headers, lower_case_collect_headers); + } + std::shared_ptr post(const std::string &url, const std::string &body) { + return this->post(url.c_str(), body); + } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers) { - return this->start(url, "POST", body, request_headers); + return this->post(url.c_str(), body, request_headers); } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + return this->post(url.c_str(), body, request_headers, lower_case_collect_headers); } // Remove before 2027.1.0 @@ -379,11 +398,15 @@ class HttpRequestComponent : public Component { return this->post(url, body, std::vector
(request_headers.begin(), request_headers.end()), collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads return this->perform(url, method, body, request_headers, {}); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers); + } // Remove before 2027.1.0 ESPDEPRECATED("Pass request_headers as std::vector
instead of std::list. Removed in 2027.1.0.", "2026.7.0") @@ -403,7 +426,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); } // Remove before 2027.1.0 @@ -418,7 +441,8 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower); } // Remove before 2027.1.0 @@ -426,19 +450,25 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower_case_collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->perform(url, method, body, request_headers, lower_case_collect_headers); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers); + } protected: - virtual std::shared_ptr perform(const std::string &url, const std::string &method, - const std::string &body, const std::vector
&request_headers, + virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, const std::vector &lower_case_collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; @@ -499,8 +529,8 @@ template class HttpRequestSendAction final : public Actionparent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, - this->lower_case_collect_headers_); + auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, + request_headers, this->lower_case_collect_headers_); auto captured_args = std::make_tuple(x...); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 43ab2e5b53a..0d968222e9e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -2,6 +2,8 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -22,8 +24,7 @@ static const char *const TAG = "http_request"; static constexpr int ESP8266_SSL_ERR_OOM = -1000; #endif -std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -37,7 +38,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur const uint32_t start = millis(); - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); @@ -70,19 +71,19 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS - bool status = container->client_.begin(*stream_ptr, url.c_str()); + bool status = container->client_.begin(*stream_ptr, url); #elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } - bool status = container->client_.begin(url.c_str()); + bool status = container->client_.begin(url); #endif App.feed_wdt(); if (!status) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.collectHeaders(header_keys, index); App.feed_wdt(); - container->status_code = container->client_.sendRequest(method.c_str(), body.c_str()); + container->status_code = container->client_.sendRequest(method, body.c_str()); App.feed_wdt(); if (container->status_code < 0) { #if defined(USE_ESP8266) && defined(USE_HTTP_REQUEST_ESP8266_HTTPS) @@ -139,7 +140,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } #endif - ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url.c_str(), + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); this->status_momentary_error("failed", 1000); @@ -147,7 +148,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return nullptr; } if (!is_success(container->status_code)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 028b9f44a1c..62737f4d0d1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -54,7 +54,7 @@ class HttpRequestArduino final : public HttpRequestComponent { #endif protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; #ifdef USE_ESP8266 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index cf231e20bdc..a7889702023 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -5,6 +5,8 @@ #include "httplib.h" #include "http_request_host.h" +#include + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,8 +18,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request"; -std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -27,10 +28,10 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } std::regex url_regex(R"(^(([^:\/?#]+):)?(//([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)", std::regex::extended); - std::smatch url_match_result; + std::cmatch url_match_result; if (!std::regex_match(url, url_match_result, url_regex) || url_match_result.length() < 7) { - ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url); return nullptr; } auto host = url_match_result[4].str(); @@ -54,7 +55,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } httplib::Client client(scheme_host.c_str()); if (!client.is_valid()) { - ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; } client.set_follow_location(this->follow_redirects_); @@ -64,41 +65,41 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, #endif httplib::Result result; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { result = client.Get(path, h_headers, [&](const char *data, size_t data_length) { ESP_LOGV(TAG, "Got data length: %zu", data_length); container->response_body_.insert(container->response_body_.end(), (const uint8_t *) data, (const uint8_t *) data + data_length); return true; }); - } else if (method == "HEAD") { + } else if (strcmp(method, "HEAD") == 0) { result = client.Head(path, h_headers); - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { result = client.Put(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { result = client.Patch(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { result = client.Post(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } } else { - ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method.c_str(), url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method, url); container->end(); return nullptr; } App.feed_wdt(); if (!result) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url.c_str(), (unsigned) result.error()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, auto response = *result; container->status_code = response.status; if (!is_success(response.status)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), response.status); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 9045702f46a..0ae9f2e27b9 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,7 +18,7 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost final : public HttpRequestComponent { public: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 10313be89db..4e5a2c42b57 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -48,8 +50,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -59,15 +60,15 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } esp_http_client_method_t method_idf; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { method_idf = HTTP_METHOD_GET; - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { method_idf = HTTP_METHOD_POST; - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { method_idf = HTTP_METHOD_PUT; - } else if (method == "DELETE") { + } else if (strcmp(method, "DELETE") == 0) { method_idf = HTTP_METHOD_DELETE; - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { this->status_momentary_error("failed", ERROR_DURATION_MS); @@ -75,11 +76,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return nullptr; } - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; esp_http_client_config_t config = {}; - config.url = url.c_str(); + config.url = url; config.method = method_idf; config.timeout_ms = this->timeout_; config.disable_auto_redirect = !this->follow_redirects_; @@ -218,7 +219,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } } - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 1c062af81b1..f84dc9576bd 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -41,7 +41,7 @@ class HttpRequestIDF final : public HttpRequestComponent { void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; } protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE From e13d4247681cf3e62289b135cd6c5f7db18fa8bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 09:41:14 -0500 Subject: [PATCH 270/759] [speaker] Remove deprecated codec_support_enabled option (#19074) --- .../speaker/media_player/__init__.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90eb19d73df..e1808889f4b 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -33,9 +31,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +39,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -103,15 +98,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +137,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, From 3804ec423f2ba3cc0a685a0278031221c8065bec Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 13:52:56 -0400 Subject: [PATCH 271/759] [i2s_audio] Resync DMA lockstep in place instead of restarting the speaker task (#19319) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 110 ++++++++++++------ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 39 ++++--- .../i2s_audio/speaker/i2s_audio_speaker.h | 19 ++- .../speaker/i2s_audio_speaker_standard.cpp | 76 ++++++++---- 4 files changed, 160 insertions(+), 84 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0e..ec4e459be78 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 0c1140da0c6..cb82b09f33a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -80,17 +80,6 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -325,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -342,6 +325,24 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; + } + xQueueReset(this->i2s_event_queue_); + + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 5812cc211b2..b443166ea1b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -134,6 +132,21 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the /// speaker task only. /// @param data Pointer to audio sample data (modified in place) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d63..b4b6173458b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; From fdf6998a8635454fd96ceb89f7b153629984a0ac Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:08:40 -0400 Subject: [PATCH 272/759] [sendspin] Start mDNS service disabled, enable once server is running (#19326) --- esphome/components/mdns/mdns_component.cpp | 4 ++++ esphome/components/sendspin/__init__.py | 8 +++++++- esphome/components/sendspin/sendspin_hub.cpp | 20 +++++++++++++++++++- esphome/components/sendspin/sendspin_hub.h | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fa39e86ed0d..9d1e5853391 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -221,6 +221,10 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fda4d4f954c..4b65cd1801a 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( @@ -11,6 +11,7 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -285,6 +286,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2cb2b909951..3216a696fb5 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,10 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; +#endif + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -69,7 +73,21 @@ void SendspinHub::setup() { } } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, + // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. + if (!this->mdns_advertised_ && this->mdns_->is_ready()) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { + this->mdns_enable_attempt_ms_ = now; + this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); + } + } +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c66c7db3ccb..daaeb2c9a57 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -10,6 +10,10 @@ #include "esphome/core/preferences.h" #include "esphome/core/version.h" +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif + #include #include #include @@ -135,6 +139,10 @@ class SendspinHub final : public Component, void set_model(const char *model) { this->model_ = model; } void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -287,6 +295,12 @@ class SendspinHub final : public Component, const char *manufacturer_{"ESPHome"}; const char *model_{nullptr}; // nullptr reports the device name instead const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; + uint32_t mdns_enable_attempt_ms_{0}; + bool mdns_advertised_{false}; +#endif }; /// @brief Base class for all sendspin subcomponents. From 6f15dc331f660a71fac552a61cf2177777a57577 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:33:41 +0000 Subject: [PATCH 273/759] Bump prek from 0.5.2 to 0.5.3 (#19358) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 010c8243e7e..81208d7cb71 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.2 # .github/workflows/ci.yml reads this pin +prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests From 845de7b7ddd7defb6cc6ccc715d90d18127bfe70 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 15:38:18 -0400 Subject: [PATCH 274/759] [sendspin] Bump sendspin-cpp to v0.8.0 (#19357) --- esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 4 ++-- esphome/idf_component.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 4b65cd1801a..49bee109361 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -282,7 +282,7 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3216a696fb5..ca443c18404 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -66,8 +66,8 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); + if (!this->client_->start()) { + ESP_LOGE(TAG, "Failed to start Sendspin client"); this->mark_failed(); return; } diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3cd5ee09b5..95337007b8f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.2 + version: 0.8.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: From bafa096a1dadd0ec4a0ef718f063910068dbe091 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:19:47 -0500 Subject: [PATCH 275/759] [display] Trim the per pixel cost of draw_pixel_at (#19360) --- esphome/components/display/display.cpp | 5 ++++- esphome/components/display/display.h | 19 +++++++++++++++++++ esphome/components/display/display_buffer.cpp | 5 ++--- esphome/components/display/rect.cpp | 10 ---------- esphome/components/display/rect.h | 10 +++++++++- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/hub75/hub75.cpp | 5 ++--- esphome/components/it8951/it8951.cpp | 4 ++-- esphome/components/mipi_dsi/mipi_dsi.cpp | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 2 +- esphome/components/mipi_spi/mipi_spi.h | 2 +- esphome/components/pixoo/pixoo.cpp | 2 +- .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 4 ++-- esphome/components/sdl/sdl_esphome.cpp | 2 +- esphome/components/st7701s/st7701s.cpp | 4 ++-- 15 files changed, 48 insertions(+), 30 deletions(-) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb600..66fadf12ece 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d1..c1389721496 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -758,6 +758,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +781,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +811,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c919140494..d564ea67bd5 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f7269175..3ecf6d1cf15 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88c..d65d844b9e6 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3214f932bfb..3b3418d911f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -299,7 +299,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d9..d36928a83af 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 179c2e5f63d..237f1c3c8bd 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0150cc25442..b6612038b6d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index c11044c2882..3f83da7f803 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -259,7 +259,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7c..550e1998bb5 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -604,7 +604,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb174..aa035be347d 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e0..f2f25741f33 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021a..a764b74581f 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce58..47b200c2de6 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { From 59a6760f5e4196d5b3f15cf481c1882bc2cbef40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:22 -0500 Subject: [PATCH 276/759] [nextion] Remove deprecated get_wave_chan_id() (#19080) --- esphome/components/nextion/nextion_component_base.h | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 5e84291b168..b66c0b9e4e0 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -66,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -86,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; From 3d4765d05a5aa86f47cd2ac580ca2a197648263c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:28 -0500 Subject: [PATCH 277/759] [template] Remove deprecated bypass_before_arming() (#19075) --- .../alarm_control_panel/template_alarm_control_panel.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830e..5888ce5e29b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. From 2f248390b282ca855b427c01584aacd473f0edf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:35 -0500 Subject: [PATCH 278/759] [modbus] Remove disable_crc validation stub (#19078) --- esphome/components/modbus/__init__.py | 16 +--------------- esphome/const.py | 1 - script/ci-custom.py | 2 +- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 0a34ed037d5..fe937587261 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,13 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_CONTINUOUS, - CONF_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -285,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional( CONF_TURNAROUND_TIME, default="600ms" ): cv.positive_time_period_milliseconds, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -297,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/const.py b/esphome/const.py index fd95df41965..5ffbf8c49ac 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -352,7 +352,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" diff --git a/script/ci-custom.py b/script/ci-custom.py index e2b7cd8d376..2c9a64c68be 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -710,7 +710,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) From 711733f9aff3e2380c2b8efe008257856af89d2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:53:43 -0500 Subject: [PATCH 279/759] [modbus] Remove deprecated send() (#19079) --- esphome/components/modbus/modbus.h | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 1623c099a34..298cd9f5278 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. From 417b841db76ce15d9c9a6f0d13f13d58fee88b7c Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Wed, 16 Sep 2026 14:53:53 -0700 Subject: [PATCH 280/759] [icnt86] Support ICNT86 touch driver (#18331) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icnt86/__init__.py | 1 + esphome/components/icnt86/icnt86.cpp | 84 +++++++++++++++++++++ esphome/components/icnt86/icnt86.h | 24 ++++++ esphome/components/icnt86/touchscreen.py | 40 ++++++++++ tests/components/icnt86/common.yaml | 24 ++++++ tests/components/icnt86/test.esp32-idf.yaml | 14 ++++ 7 files changed, 188 insertions(+) create mode 100644 esphome/components/icnt86/__init__.py create mode 100644 esphome/components/icnt86/icnt86.cpp create mode 100644 esphome/components/icnt86/icnt86.h create mode 100644 esphome/components/icnt86/touchscreen.py create mode 100644 tests/components/icnt86/common.yaml create mode 100644 tests/components/icnt86/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9b34d523d20..7f44f8323c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core esphome/components/improv_ble/* @jesserockz diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 00000000000..07f3b4e31ca --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 00000000000..62a4586ebc3 --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 00000000000..0d96b015247 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 00000000000..5d7a7386120 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/tests/components/icnt86/common.yaml b/tests/components/icnt86/common.yaml new file mode 100644 index 00000000000..1537bb8b762 --- /dev/null +++ b/tests/components/icnt86/common.yaml @@ -0,0 +1,24 @@ +touchscreen: + - platform: icnt86 + i2c_id: i2c_bus + interrupt_pin: ${interrupt_pin_touch} + reset_pin: ${reset_pin_touch} + display: epaper + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] + +display: + - platform: waveshare_epaper + id: epaper + rotation: 90 + cs_pin: ${cs_pin_display} + dc_pin: ${dc_pin_display} + busy_pin: ${busy_pin_display} + reset_pin: ${reset_pin_display} + model: 2.90inv2-r2 + pages: + - id: icnt86_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/icnt86/test.esp32-idf.yaml b/tests/components/icnt86/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0b882292a5 --- /dev/null +++ b/tests/components/icnt86/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + interrupt_pin_touch: GPIO4 + reset_pin_touch: GPIO32 + cs_pin_display: GPIO33 + dc_pin_display: GPIO21 + busy_pin_display: GPIO27 + reset_pin_display: GPIO14 + clk_pin: GPIO25 + mosi_pin: GPIO26 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icnt86: !include common.yaml From cfcaeff27b376fdd63c3c2c9b8abdd3ad75f375b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:57:47 -0500 Subject: [PATCH 281/759] [esp32_hosted] Keep the update manifest URL as a pointer to the literal (#19212) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 4 ++-- esphome/components/esp32_hosted/update/esp32_hosted_update.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 4eb5d1745be..d9b375dd20c 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -169,7 +169,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +215,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738dd..c319852bff9 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers From dc300ae5d451c0b6db98924fc9b512af331bf316 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:14 -0500 Subject: [PATCH 282/759] [esp8266] Drop the dead NEW_OOM_ABORT flag and its nothrow advice (#19247) --- esphome/components/esp8266/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29a..cef0e6ea11d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -363,14 +363,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. # See throw_stubs.h for details. Must be prepended before , so this From 7386daed5fcb2937e97b3869c59232e66a56a3ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 16:58:45 -0500 Subject: [PATCH 283/759] [ld2450] Use a user provided default constructor for MultiTargetSwitch (#19192) --- esphome/components/ld2450/switch/multi_target_switch.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce9..d711a2d2d29 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; From 9288311978465380bca196eae6e890ecd6d112fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 17:19:04 -0500 Subject: [PATCH 284/759] [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) --- esphome/components/esphome/ota/__init__.py | 12 +++++++-- tests/component_tests/ota/test_esphome_ota.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260c..bcf2a2271cb 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc1..235ad902dbc 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" From bb5c06c58a7f2a9d56bcf5c8171caa4fab671110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:17:56 -0500 Subject: [PATCH 285/759] [image] Drop the unused stride_ member (#19353) --- esphome/components/image/image.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f200..fd9e92c21d3 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif From f8012bc467492feb6cfd9f8e7348c62d452b62c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:22:32 -0500 Subject: [PATCH 286/759] [esp32] Redefine __FILE__ to the basename so assert paths stay out of RAM (#19106) --- esphome/components/esp32/__init__.py | 7 +++++++ .../esp32/config/file_macro_idf_5_0.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/component_tests/esp32/config/file_macro_idf_5_0.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d027c9a1c61..de25afa23be 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2609,6 +2609,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml new file mode 100644 index 00000000000..22ee1e480e6 --- /dev/null +++ b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + version: 5.0.6 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 2dd2a50c83c..777759e8afc 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -658,6 +658,27 @@ def test_platformio_arduino_enables_reproducible_build( assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("reproducible_build.yaml", True), + ("reproducible_build_arduino.yaml", True), + ("file_macro_idf_5_0.yaml", False), + ], +) +def test_file_macro_is_basename_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + generate_main(component_config_path(config_file)) + + assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected + assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + + def test_native_idf_enables_reproducible_build( component_config_path: Callable[[str], Path], ) -> None: From abfe350119e48063540e8569830a7dd8a008fc01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:23:29 -0500 Subject: [PATCH 287/759] [core] Fold the modbus write and bits tests into the shared mesh fixture (#18946) --- .../fixtures/uart_mock_modbus_mesh.yaml | 292 +++++++++++++- ...rt_mock_modbus_server_controller_bits.yaml | 147 ------- ...t_mock_modbus_server_controller_write.yaml | 371 ------------------ tests/integration/test_uart_mock_modbus.py | 140 ++++--- 4 files changed, 340 insertions(+), 610 deletions(-) delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml delete mode 100644 tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 69edd614d7a..977cdd359be 100644 --- a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -17,10 +17,10 @@ uart: baud_rate: 115200 port: /dev/null -# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only -# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second -# server hub. auto_start everywhere: the controller polls at boot, so the -# forwarding must already be live or early requests generate warnings. +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers +# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6 +# on the second server hub. auto_start everywhere: the controller polls at +# boot, so the forwarding must already be live or early requests generate warnings. # Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server @@ -64,6 +64,54 @@ globals: - id: stored_1 type: uint16_t initial_value: "0" + - id: stored_u_word + type: uint16_t + initial_value: "99" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" + - id: stored_s_word + type: int16_t + initial_value: "-99" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" + - id: stored_u_dword + type: uint32_t + initial_value: "16909060" + - id: stored_s_dword + type: int32_t + initial_value: "-16909060" + - id: stored_u_dword_r + type: uint32_t + initial_value: "67305985" + - id: stored_s_dword_r + type: int32_t + initial_value: "-67305985" + - id: stored_u_qword + type: uint64_t + initial_value: "72623859790382856" + - id: stored_s_qword + type: int64_t + initial_value: "-72623859790382856" + - id: stored_u_qword_r + type: uint64_t + initial_value: "578437695752307201" + - id: stored_s_qword_r + type: int64_t + initial_value: "-578437695752307201" + - id: stored_fp32 + type: float + initial_value: "3.14" + - id: stored_fp32_r + type: float + initial_value: "2.5" + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" modbus: - uart_id: virtual_uart_server @@ -90,6 +138,10 @@ modbus_controller: modbus_id: virtual_modbus_client id: modbus_controller_3 update_interval: 1s + - address: 6 + modbus_id: virtual_modbus_client + id: modbus_controller_6 + update_interval: 1s modbus_server: - address: 1 @@ -97,46 +149,60 @@ modbus_server: registers: - address: 0x01 value_type: U_WORD - read_lambda: return 99; + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; - address: 0x02 value_type: U_WORD_S - read_lambda: return 4660; + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD - read_lambda: return -99; + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; - address: 0x04 value_type: S_WORD_S - read_lambda: return -2; + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD - read_lambda: return 16909060; + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; - address: 0x08 value_type: S_DWORD - read_lambda: return -16909060; + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; - address: 0x0B value_type: U_DWORD_R - read_lambda: return 67305985; + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; - address: 0x0E value_type: S_DWORD_R - read_lambda: return -67305985; + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; - address: 0x11 value_type: U_QWORD - read_lambda: return 72623859790382856; + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; - address: 0x16 value_type: S_QWORD - read_lambda: return -72623859790382856; + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; - address: 0x1B value_type: U_QWORD_R - read_lambda: return 578437695752307201; + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; - address: 0x20 value_type: S_QWORD_R - read_lambda: return -578437695752307201; + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; - address: 0x25 value_type: FP32 - read_lambda: return 3.14; + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; - address: 0x28 value_type: FP32_R - read_lambda: return 3.14; + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; - address: 5 modbus_id: virtual_modbus_server registers: @@ -165,6 +231,19 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 929; + - address: 6 + modbus_id: virtual_modbus_server_2 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; sensor: - platform: modbus_controller @@ -280,6 +359,183 @@ sensor: name: "client_read_1" id: client_read_1 +# The number schema caps min/max at 16777215 (float32 integer precision), so +# the large dword/qword baselines cannot be written back through these numbers. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02); +# the server serves both from one shared table, so the two views must agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml deleted file mode 100644 index cb6fc6f0740..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml +++ /dev/null @@ -1,147 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-bits - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_bit_2 - type: bool - initial_value: "false" - - id: stored_bit_3 - type: bool - initial_value: "true" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 1s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - bits: - - address: 0x00 - read_lambda: return true; - - address: 0x01 - read_lambda: return false; - - address: 0x02 - read_lambda: return id(stored_bit_2); - write_lambda: id(stored_bit_2) = x; return true; - - address: 0x03 - read_lambda: return id(stored_bit_3); - write_lambda: id(stored_bit_3) = x; return true; - -# The same four bits are read both as coils (FC 0x01) and as discrete inputs -# (FC 0x02): the server serves both from one shared bit table, so the two -# views must always agree. -binary_sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_0" - address: 0x00 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_1" - address: 0x01 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_3" - address: 0x03 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_0" - address: 0x00 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_1" - address: 0x01 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_2" - address: 0x02 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_3" - address: 0x03 - register_type: discrete_input - -# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the -# multiple-coils write (FC 0x0F) so both server write paths are exercised. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_3" - address: 0x03 - register_type: coil - use_write_multiple: true - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml deleted file mode 100644 index 5ade49bd48c..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ /dev/null @@ -1,371 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_u_word - type: uint16_t - initial_value: "11" - - id: stored_u_word_s - type: uint16_t - initial_value: "4660" - - id: stored_s_word - type: int16_t - initial_value: "-11" - - id: stored_s_word_s - type: int16_t - initial_value: "-2" - - id: stored_u_dword - type: uint32_t - initial_value: "1001" - - id: stored_s_dword - type: int32_t - initial_value: "-1001" - - id: stored_u_dword_r - type: uint32_t - initial_value: "3003" - - id: stored_s_dword_r - type: int32_t - initial_value: "-3003" - - id: stored_u_qword - type: uint64_t - initial_value: "5005" - - id: stored_s_qword - type: int64_t - initial_value: "-5005" - - id: stored_u_qword_r - type: uint64_t - initial_value: "7007" - - id: stored_s_qword_r - type: int64_t - initial_value: "-7007" - - id: stored_fp32 - type: float - initial_value: "1.5" - - id: stored_fp32_r - type: float - initial_value: "2.5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 2s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return id(stored_u_word); - write_lambda: id(stored_u_word) = x; return true; - - address: 0x02 - value_type: U_WORD_S - read_lambda: return id(stored_u_word_s); - write_lambda: id(stored_u_word_s) = x; return true; - - address: 0x03 - value_type: S_WORD - read_lambda: return id(stored_s_word); - write_lambda: id(stored_s_word) = x; return true; - - address: 0x04 - value_type: S_WORD_S - read_lambda: return id(stored_s_word_s); - write_lambda: id(stored_s_word_s) = x; return true; - - address: 0x05 - value_type: U_DWORD - read_lambda: return id(stored_u_dword); - write_lambda: id(stored_u_dword) = x; return true; - - address: 0x08 - value_type: S_DWORD - read_lambda: return id(stored_s_dword); - write_lambda: id(stored_s_dword) = x; return true; - - address: 0x0B - value_type: U_DWORD_R - read_lambda: return id(stored_u_dword_r); - write_lambda: id(stored_u_dword_r) = x; return true; - - address: 0x0E - value_type: S_DWORD_R - read_lambda: return id(stored_s_dword_r); - write_lambda: id(stored_s_dword_r) = x; return true; - - address: 0x11 - value_type: U_QWORD - read_lambda: return id(stored_u_qword); - write_lambda: id(stored_u_qword) = x; return true; - - address: 0x16 - value_type: S_QWORD - read_lambda: return id(stored_s_qword); - write_lambda: id(stored_s_qword) = x; return true; - - address: 0x1B - value_type: U_QWORD_R - read_lambda: return id(stored_u_qword_r); - write_lambda: id(stored_u_qword_r) = x; return true; - - address: 0x20 - value_type: S_QWORD_R - read_lambda: return id(stored_s_qword_r); - write_lambda: id(stored_s_qword_r) = x; return true; - - address: 0x25 - value_type: FP32 - read_lambda: return id(stored_fp32); - write_lambda: id(stored_fp32) = x; return true; - - address: 0x28 - value_type: FP32_R - read_lambda: return id(stored_fp32_r); - write_lambda: id(stored_fp32_r) = x; return true; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - min_value: -16777215 - max_value: 16777215 - step: 0.01 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - min_value: -16777215 - max_value: 16777215 - step: 0.01 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 232e1fb6543..36aa9a9668c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -19,23 +19,40 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest -from .state_utils import SensorTracker, find_entity, wait_for_state +from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction -@dataclass -class RegisterTestCase: - """Test parameters for a single modbus register write/read round-trip.""" +def _swap16(value: int) -> int: + """Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" + return ((value & 0xFF) << 8) | (value >> 8) - initial_value: object - write_number_name: str - write_value: float - post_write_value: object + +# Raw U_WORD view of reg_u_word_s's initial 0x1234 +MESH_RAW_U_WORD_S = _swap16(4660) + +# Initial values of the mesh fixture's address 1 registers; the +# server_controller test reads them and the write test uses them as baseline. +MESH_INITIAL_VALUES: dict[str, object] = { + "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_s_word": -99, + "reg_s_word_s": -2, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(2.5), +} # --------------------------------------------------------------------------- @@ -310,23 +327,7 @@ async def test_uart_mock_modbus_server_controller( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = { - "reg_u_word": 99, - "reg_u_word_s": 4660, - "reg_u_word_s_raw": 13330, - "reg_s_word": -99, - "reg_s_word_s": -2, - "reg_u_dword": 16909060, - "reg_s_dword": -16909060, - "reg_u_dword_r": pytest.approx(67305985), - "reg_s_dword_r": pytest.approx(-67305985), - "reg_u_qword": pytest.approx(72623859790382856), - "reg_s_qword": pytest.approx(-72623859790382856), - "reg_u_qword_r": pytest.approx(578437695752307201), - "reg_s_qword_r": pytest.approx(-578437695752307201), - "reg_fp32": pytest.approx(3.14), - "reg_fp32_r": pytest.approx(3.14), - } + expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -334,14 +335,12 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_write( yaml_config: str, @@ -357,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - register_test_cases: dict[str, RegisterTestCase] = { - "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), - "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), - "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), - "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), - "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), - "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), - "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), - "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), - "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), - "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), - "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), - "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), - "reg_fp32": RegisterTestCase( - pytest.approx(1.5, abs=0.01), - "write_fp32", - 3.14, - pytest.approx(3.14, abs=0.01), - ), - "reg_fp32_r": RegisterTestCase( - pytest.approx(2.5, abs=0.01), - "write_fp32_r", - 6.28, - pytest.approx(6.28, abs=0.01), - ), + # Per read-back sensor: the number entity to write through and the value; + # floats read back within tolerance, everything else exactly + register_writes: dict[str, tuple[str, int | float]] = { + "reg_u_word": ("write_u_word", 42), + "reg_u_word_s": ("write_u_word_s", 17185), + "reg_s_word": ("write_s_word", -42), + "reg_s_word_s": ("write_s_word_s", -257), + "reg_u_dword": ("write_u_dword", 2002), + "reg_s_dword": ("write_s_dword", -2002), + "reg_u_dword_r": ("write_u_dword_r", 4004), + "reg_s_dword_r": ("write_s_dword_r", -4004), + "reg_u_qword": ("write_u_qword", 6006), + "reg_s_qword": ("write_s_qword", -6006), + "reg_u_qword_r": ("write_u_qword_r", 8008), + "reg_s_qword_r": ("write_s_qword_r", -8008), + "reg_fp32": ("write_fp32", 6.28), + "reg_fp32_r": ("write_fp32_r", 9.42), } - tracker = SensorTracker(list(register_test_cases.keys())) + tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) + # The raw U_WORD view of 0x02 pins the byte swap on the write path: the + # round trip through write_u_word_s applies the swap an even number of + # times, so only the raw sensor can catch a symmetrically dropped swap. # Phase 1: expect initial baseline values initial_futures = tracker.expect_all( - {name: case.initial_value for name, case in register_test_cases.items()} + MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} ) # Phase 2: expect post-write values (registered now so on_state can match them) written_futures = tracker.expect_all( - {name: case.post_write_value for name, case in register_test_cases.items()} + { + name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value + for name, (_, value) in register_writes.items() + } + | {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])} ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the baseline can already be in the - # states the device sends on connect; matching it there saves waiting for - # the next poll entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -410,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write( # connection is working before issuing writes await tracker.await_all(initial_futures, timeout=4.0) - # Issue write commands for all register types - for case in register_test_cases.values(): - entity = find_entity(entities, case.write_number_name, NumberInfo) - assert entity is not None, ( - f"{case.write_number_name} number entity not found" - ) - client.number_command(entity.key, case.write_value) + # Issue write commands for all register types; exact object_id match, + # since several write_* names are prefixes of a sibling + numbers = { + e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) + } + for number_name, value in register_writes.values(): + entity = numbers.get(number_name) + assert entity is not None, f"{number_name} number entity not found" + client.number_command(entity.key, value) # Wait for sensors to reflect the written values (round-trip write+read) await tracker.await_all(written_futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_bits( yaml_config: str, @@ -468,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot and binary sensors drop repeats, so the - # baseline can arrive only in the states the device sends on connect entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -480,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits( # Flip both writable bits: 0x02 false -> true, 0x03 true -> false for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): - entity = find_entity(entities, switch_name, SwitchInfo) - assert entity is not None, f"{switch_name} switch entity not found" + entity = require_entity(entities, switch_name, SwitchInfo) client.switch_command(entity.key, value) # Wait for both read views to reflect the written values @@ -508,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) From 3fe87688d92d7272ecd969aa24b9ac7c42e2d841 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:31:27 -0500 Subject: [PATCH 288/759] [esp32] Enable octal flash when the flash mode is opi (#19218) --- esphome/components/esp32/__init__.py | 9 ++++++++ .../esp32/config/flash_mode_opi_s3.yaml | 8 +++++++ tests/component_tests/esp32/test_esp32.py | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/component_tests/esp32/config/flash_mode_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index de25afa23be..0c5b9c5df6a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1519,6 +1519,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -2732,6 +2739,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True diff --git a/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml new file mode 100644 index 00000000000..82262f63493 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 777759e8afc..d5d0acfb2ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -252,6 +252,16 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "flash_mode": "opi", + "framework": {"type": "esp-idf"}, + }, + r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]", + id="flash_mode_opi_only_on_s3", + ), ], ) def test_esp32_configuration_errors( @@ -704,10 +714,22 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False assert CORE.platformio_options.get("board_build.flash_mode") == "qio" assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +def test_flash_mode_opi_enables_octal_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode.""" + generate_main(component_config_path("flash_mode_opi_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True + + def test_flash_mode_unset_leaves_defaults( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From ffb1ad288f29faed92e8591c38724ed5efba721e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:32:13 -0500 Subject: [PATCH 289/759] [remote_receiver] Wake the main loop when the RMT callback stores a frame (#19102) --- .../remote_receiver/remote_receiver_rmt.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 4eebbbb16f1..e4ffd7e1105 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -14,25 +15,32 @@ static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + return task_woken != pdFALSE; } void RemoteReceiverComponent::setup() { From b001514cf30f616d698f2839ffd3a005272d6b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 18:33:52 -0500 Subject: [PATCH 290/759] [remote_base] Accept protocols registered by external components (#19332) --- esphome/components/remote_base/__init__.py | 14 ++++-- .../receiver_with_external_protocol.yaml | 36 +++++++++++++++ .../fake_protocol/__init__.py | 39 ++++++++++++++++ .../remote_receiver/test_slot_counts.py | 46 ++++++++++++++++++- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml create mode 100644 tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 27b6eb9fc82..bf8707ff1e8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -169,6 +169,12 @@ def request_protocol(name: str) -> None: cg.add_define(protocol_define(name)) +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + # Only the protocol sources a configuration uses are compiled FILTER_SOURCE_FILES = filter_source_files_from_defines( {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} @@ -182,7 +188,7 @@ def register_binary_sensor( def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: async def new_func(var: MockObj, config: ConfigType) -> None: - request_protocol(name) + _request_protocol_if_in_tree(name) await coroutine(func)(var, config) return registerer(new_func) @@ -200,7 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -259,7 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: diff --git a/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml new file mode 100644 index 00000000000..e094e5bd52e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml @@ -0,0 +1,36 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +external_components: + - source: + type: local + path: ../external_components + +fake_protocol: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - fake + - nec + on_fake: + then: + - remote_transmitter.transmit_fake: + on_nec: + then: + - logger.log: nec + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +binary_sensor: + - platform: remote_receiver + name: Fake Input + fake: diff --git a/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py new file mode 100644 index 00000000000..971497aa794 --- /dev/null +++ b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py @@ -0,0 +1,39 @@ +"""External component registering a protocol that has no source file in remote_base.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.types import ConfigType + +DEPENDENCIES = ["remote_base"] + +ns = cg.esphome_ns.namespace("fake_protocol") +FakeData = ns.struct("FakeData") +FakeBinarySensor = ns.class_( + "FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase +) +FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger) +FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase) +FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase) + +CONFIG_SCHEMA = cv.Schema({}) + + +@remote_base.register_binary_sensor("fake", FakeBinarySensor, {}) +def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_trigger("fake", FakeTrigger, FakeData) +def fake_trigger(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_dumper("fake", FakeDumper) +def fake_dumper(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_action("fake", FakeAction, {}) +async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None: + pass diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index 4d69e6d923b..ee79e9a06d5 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -1,13 +1,16 @@ """Listener and dumper StaticVector sizes come from codegen slot counts.""" -from collections.abc import Callable +from collections.abc import Callable, Generator from pathlib import Path +import sys import pytest +from esphome import loader from esphome.automation import ACTION_REGISTRY from esphome.components import remote_base import esphome.config_validation as cv +from esphome.core import CORE from ..helpers import get_define_value @@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None: assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name +@pytest.fixture +def restore_protocol_registries() -> Generator[None]: + """Loading an external protocol component adds to module-level registries; undo that. + + The loader caches the component too, so drop it or a second load would skip the + decorators and leave the restored registries without the external names. + """ + registries = ( + remote_base.BINARY_SENSOR_REGISTRY, + remote_base.TRIGGER_REGISTRY, + remote_base.DUMPER_REGISTRY, + ACTION_REGISTRY, + ) + saved = [dict(registry) for registry in registries] + yield + for registry, entries in zip(registries, saved, strict=True): + registry.clear() + registry.update(entries) + loader._COMPONENT_CACHE.pop("fake_protocol", None) + sys.modules.pop("esphome.components.fake_protocol", None) + + +@pytest.mark.usefixtures("restore_protocol_registries") +def test_external_protocols_register_without_a_remote_base_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An external protocol goes through all four decorators without a source file here, so no define is emitted.""" + main_cpp = generate_main( + component_config_path("receiver_with_external_protocol.yaml") + ) + defines = {define.name for define in CORE.defines} + assert "USE_REMOTE_PROTOCOL_NEC" in defines + assert "USE_REMOTE_PROTOCOL_FAKE" not in defines + for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"): + assert f"fake_protocol::{cls}" in main_cpp, cls + # fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3" + + def test_request_protocol_rejects_unknown_names() -> None: """A misspelled protocol would otherwise surface only as a link error.""" with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): From 8bcb5004da8af416f11028bf4609f2c78ca104e5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 19:35:07 -0400 Subject: [PATCH 291/759] [sendspin] Add a switch platform to enable and disable the client (#19361) --- CODEOWNERS | 1 + .../media_player/sendspin_media_player.cpp | 4 ++ .../media_source/sendspin_media_source.cpp | 17 ++++- esphome/components/sendspin/sendspin_hub.cpp | 67 +++++++++++++------ esphome/components/sendspin/sendspin_hub.h | 28 ++++++-- .../components/sendspin/switch/__init__.py | 31 +++++++++ .../sendspin/switch/sendspin_switch.cpp | 26 +++++++ .../sendspin/switch/sendspin_switch.h | 24 +++++++ esphome/core/defines.h | 1 + tests/components/sendspin/common-switch.yaml | 6 ++ .../sendspin/test-switch.esp32-idf.yaml | 2 + 11 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 esphome/components/sendspin/switch/__init__.py create mode 100644 esphome/components/sendspin/switch/sendspin_switch.cpp create mode 100644 esphome/components/sendspin/switch/sendspin_switch.h create mode 100644 tests/components/sendspin/common-switch.yaml create mode 100644 tests/components/sendspin/test-switch.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7f44f8323c3..aba498c3652 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -478,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f421..59ead1bb539 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e831..c3fb1fe1cbd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index ca443c18404..58ec57c7681 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,10 +21,6 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; -#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE -static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000; -#endif - #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; @@ -66,26 +62,24 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start()) { - ESP_LOGE(TAG, "Failed to start Sendspin client"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } this->client_->loop(); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE - // mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries, - // rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass. - if (!this->mdns_advertised_ && this->mdns_->is_ready()) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) { - this->mdns_enable_attempt_ms_ = now; - this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true); - } - } + this->update_mdns_service_(); #endif } @@ -114,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -251,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index daaeb2c9a57..b00fdc436eb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -97,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -105,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -115,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -130,6 +130,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -212,6 +223,11 @@ class SendspinHub final : public Component, /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -290,6 +306,9 @@ class SendspinHub final : public Component, bool task_stack_in_psram_{false}; + // Requested client state, applied from loop(). Empty until the switch restores its state. + std::optional enabled_; + // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. const char *manufacturer_{"ESPHome"}; @@ -298,8 +317,7 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - uint32_t mdns_enable_attempt_ms_{0}; - bool mdns_advertised_{false}; + bool mdns_advertised_{false}; // Last state requested from mdns #endif }; diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 00000000000..63f5f7ad28b --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) + +CONFIG_SCHEMA = cv.All( + switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 00000000000..0bf029d4c77 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 00000000000..253d952b220 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b2b5267b112..bc1418c6e2b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -400,6 +400,7 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml new file mode 100644 index 00000000000..d332cb0dde0 --- /dev/null +++ b/tests/components/sendspin/common-switch.yaml @@ -0,0 +1,6 @@ +packages: + sendspin: !include common.yaml + +switch: + - platform: sendspin + name: "Sendspin Enabled" diff --git a/tests/components/sendspin/test-switch.esp32-idf.yaml b/tests/components/sendspin/test-switch.esp32-idf.yaml new file mode 100644 index 00000000000..d32c14c054a --- /dev/null +++ b/tests/components/sendspin/test-switch.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-switch.yaml From 1a04359454be71e184b21b6227660b9b476145f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 19:04:14 -0500 Subject: [PATCH 292/759] [switch] Use Switch::control() instead of hand written turn_on/turn_off branches (#19364) --- esphome/components/api/api_connection.cpp | 7 +----- .../components/copy/switch/copy_switch.cpp | 8 +------ .../components/gpio/switch/gpio_switch.cpp | 12 ++-------- esphome/components/ld6002b/ld6002b.cpp | 20 ++++------------ .../switch/modbus_switch.cpp | 6 +---- .../output/switch/output_switch.cpp | 10 +------- esphome/components/sprinkler/sprinkler.cpp | 24 ++++--------------- esphome/components/switch/switch.cpp | 1 - .../template/switch/template_switch.cpp | 6 +---- 9 files changed, 15 insertions(+), 79 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 749eaeb3929..3064ff09b18 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -720,12 +720,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0a..555f0030a5b 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a4..d231b3d77a5 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df3311..aa34ad9d392 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index f2aae201f33..855a7b28c30 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,11 +16,7 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a86398..325514ddf40 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,15 +6,7 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { if (state) { this->output_->turn_on(); diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208bb..cdec1581266 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 8413c7b4936..57e4f222bce 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index edd753d3d2b..729db370531 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -42,11 +42,7 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { From 34158e17b885bcc749fac6003b2be665a3a2765f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 16 Sep 2026 21:42:29 -0400 Subject: [PATCH 293/759] [mixer] Raise ducking decibel_reduction maximum to 255 (#19347) --- esphome/components/mixer/speaker/__init__.py | 2 +- tests/components/mixer/common.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3746c019a0..26619f35a76 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -155,7 +155,7 @@ async def to_code(config: ConfigType) -> None: { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index 55e96df4c27..489475c794f 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -3,7 +3,7 @@ esphome: then: - mixer_speaker.apply_ducking: id: source_speaker_1_id - decibel_reduction: 10 + decibel_reduction: 255 duration: 1s speaker: From 4be83021904a0f028124d500c76bba8bad1bd412 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:36:08 +1200 Subject: [PATCH 294/759] [ci] Move skills to .agents/skills with .claude and .github symlinks (#19369) --- {.claude => .agents}/skills/pr-workflow/SKILL.md | 0 .claude/skills | 1 + .github/skills | 1 + script/ci-custom.py | 3 +++ 4 files changed, 5 insertions(+) rename {.claude => .agents}/skills/pr-workflow/SKILL.md (100%) create mode 120000 .claude/skills create mode 120000 .github/skills diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/skills b/.github/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/script/ci-custom.py b/script/ci-custom.py index 2c9a64c68be..286dda85b9e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -247,6 +247,9 @@ def lint_ext_check(fname): "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", + # Symlinks to the shared .agents/skills directory + ".claude/skills", + ".github/skills", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] From cf0a87de28cd74b2171964c32c113099ed2544a3 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Thu, 17 Sep 2026 04:47:40 -0500 Subject: [PATCH 295/759] [mixer] Don't discard a start request while reaping a stopped task (#19368) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 7d33b6c49f8..41b7123269a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -385,7 +385,8 @@ void MixerSpeaker::loop() { // Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } From 8cd22eaceae171c9c01e297424e2532759af3ad9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 17 Sep 2026 08:23:55 -0400 Subject: [PATCH 296/759] [audio] Bump esp-audio-libs to v4.0.1 (#19372) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 14a08188949..b882aaa6b75 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -339,7 +339,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 95337007b8f..d12a27221b9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: - version: 4.0.0 + version: 4.0.1 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 7e3d4a48d156f100b0bcb9bd45a801a321f45568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 07:56:29 -0500 Subject: [PATCH 297/759] [modbus_controller] Remove deprecated helper shims (#19076) --- .../modbus_controller/modbus_controller.h | 74 +------------------ 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 741d4f6f00d..d21b3194358 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; #pragma GCC diagnostic pop -// Remove before 2026.10.0 — these helpers have moved to modbus::helpers -ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") -inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } - -ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_read_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_write_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } - -ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::byte_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::word_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::dword_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::qword_from_hex_str(value, pos); -} - -template -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(data, buffer_offset); -} - -// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a // std::span (previously a const std::vector &), and a span does not convert to // a vector, so existing lambdas calling these by name need an overload that accepts one. These carry -// this release's deprecation window, since the span forms only exist from it. +// the 2026.8.0 deprecation window, since the span forms only exist from it. // payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers // type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here // would only make that call ambiguous. @@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span data) { return modbus::helpers::bit_from_packed(coil, data); } -template -ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") -N mask_and_shift_by_rightbit(N data, uint32_t mask) { - return modbus::helpers::mask_and_shift_by_rightbit(data, mask); -} - -ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - modbus::helpers::number_to_payload(data, value, value_type); -} - -ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") -inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) - .value_or(0); -} - -ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline std::vector float_to_payload(float value, SensorValueType value_type) { - std::vector data; - modbus::helpers::float_to_payload(data, value, value_type); - return data; -} - -class ModbusController; - /// How an item relates to the register range built just before it (same register type, address order). /// The numeric order doubles as the comparator tiebreak for items at the same address (see /// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and From 942322738a4d2d15ecee759e31ac90d1ff447fb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:14:57 -0500 Subject: [PATCH 298/759] [remote_receiver] [remote_transmitter] Keep the RMT setup error message as a pointer to the literal (#19210) --- .../remote_receiver/remote_receiver.h | 4 +- .../remote_receiver/remote_receiver_rmt.cpp | 39 ++++++------------- .../remote_transmitter/remote_transmitter.h | 4 +- .../remote_transmitter_rmt.cpp | 35 ++++++----------- 4 files changed, 27 insertions(+), 55 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index e59a8b25573..997863f032b 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, protected: #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void decode_rmt_(rmt_symbol_word_t *item, size_t item_count); + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; bool with_dma_{false}; uint32_t carrier_frequency_{0}; uint8_t carrier_duty_percent_{100}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index e4ffd7e1105..64392aa7eeb 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -43,6 +43,11 @@ static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_r return task_woken != pdFALSE; } +void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteReceiverComponent::setup() { rmt_rx_channel_config_t channel; memset(&channel, 0, sizeof(channel)); @@ -55,13 +60,8 @@ void RemoteReceiverComponent::setup() { channel.flags.with_dma = this->with_dma_; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_rx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel")); return; } if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -71,9 +71,7 @@ void RemoteReceiverComponent::setup() { } error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } @@ -85,9 +83,7 @@ void RemoteReceiverComponent::setup() { carrier.flags.polarity_active_low = this->pin_->is_inverted(); error = rmt_apply_carrier(this->channel_, &carrier); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -97,9 +93,7 @@ void RemoteReceiverComponent::setup() { callbacks.on_recv_done = rmt_callback; error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_rx_register_event_callbacks"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks")); return; } @@ -122,9 +116,7 @@ void RemoteReceiverComponent::setup() { error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_receive"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_receive")); return; } } @@ -148,18 +140,11 @@ void RemoteReceiverComponent::dump_config() { (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteReceiverComponent::loop() { if (this->store_.error != ESP_OK) { - ESP_LOGE(TAG, "Receive error"); - this->error_code_ = this->store_.error; - this->error_string_ = "in rmt_callback"; - this->mark_failed(); + this->fail_(this->store_.error, LOG_STR("in rmt_callback")); } if (this->store_.overflow) { ESP_LOGW(TAG, "Buffer overflow"); diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60e..99e1ce9504d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); void configure_rmt_(); void wait_for_rmt_(); @@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472f..6d27be8d472 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size } #endif +void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() { if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) { ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_); } - - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteTransmitterComponent::digital_write(bool value) { @@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() { #endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_tx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel")); return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() { encoder.min_chunk_size = 1; error = rmt_new_simple_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_simple_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_simple_encoder")); return; } #else @@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() { memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_copy_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_copy_encoder")); return; } #endif error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } this->digital_write(open_drain || this->inverted_); @@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() { error = rmt_apply_carrier(this->channel_, &carrier); } if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } From a7559547b27f2f3179071ab782d990fe1583d9ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:15:13 -0500 Subject: [PATCH 299/759] [remote_receiver] Treat buffer size as bytes on the pulse ring targets (#19101) --- .../components/remote_receiver/__init__.py | 15 ++++---- .../remote_receiver/remote_receiver.cpp | 15 ++++---- .../remote_receiver/remote_receiver.h | 2 +- .../config/receiver_bk72xx.yaml | 9 +++++ .../config/receiver_esp32_c61.yaml | 12 ++++++ .../config/receiver_ln882x.yaml | 9 +++++ .../remote_receiver/config/receiver_rp2.yaml | 9 +++++ .../config/receiver_rtl87xx.yaml | 9 +++++ .../remote_receiver/test_buffer_size.py | 38 ++++++++++++++----- 9 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_ln882x.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rp2.yaml create mode 100644 tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6eaecf7ab00..866e108131e 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -112,20 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers, cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, + # pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses cv.SplitDefault( CONF_BUFFER_SIZE, esp32=cv.UNDEFINED, # the pulse ring needs a size; only RMT targets size themselves in setup() **{ - f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b" + f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b" for variant in esp32_rmt.VARIANTS_NO_RMT }, - esp8266="1000b", - bk72xx="1000b", - ln882x="1000b", - rtl87xx="1000b", - rp2="1000b", - ): cv.All(cv.validate_bytes, cv.int_range(min=64)), + esp8266="4000b", + bk72xx="4000b", + ln882x="4000b", + rtl87xx="4000b", + rp2="4000b", + ): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765b..b3e4649096b 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_ int32_t multiplier = ((int32_t) level << 1) - 1; uint32_t buffer_write = arg->buffer_write; arg->buffer[buffer_write++] = (int32_t) delta * multiplier; - if (buffer_write >= arg->buffer_size) { + if (buffer_write >= arg->buffer_entries) { buffer_write = 0; } @@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() { this->store_.idle_us = this->idle_us_; this->store_.filter_us = this->filter_us_; this->store_.pin = this->pin_->to_isr(); - this->store_.buffer = new int32_t[this->buffer_size_]; - this->store_.buffer_size = this->buffer_size_; + // rounded up so a size that is not a multiple of four never holds less than requested + this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t); + this->store_.buffer = new int32_t[this->store_.buffer_entries]; this->store_.prev_micros = micros(); this->store_.commit_micros = this->store_.prev_micros; this->store_.prev_level = this->pin_->digital_read(); @@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG( TAG, "Remote Receiver:\n" - " Buffer Size: %" PRIu32 "\n" + " Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n" " Tolerance: %" PRIu32 "%s\n" " Filter out pulses shorter than: %" PRIu32 " us\n" " Signal is done after %" PRIu32 " us of no changes", - this->buffer_size_, this->tolerance_, + this->buffer_size_, this->store_.buffer_entries, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); @@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() { while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { reserve_size++; temp_read++; - if (temp_read >= s.buffer_size) { + if (temp_read >= s.buffer_entries) { temp_read = 0; } } @@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() { // read the buffer for (uint32_t i = 0; i < reserve_size + 1; i++) { this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); - if (s.buffer_read >= s.buffer_size) { + if (s.buffer_read >= s.buffer_entries) { s.buffer_read = 0; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 997863f032b..6f93979b183 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore { uint32_t buffer_read{0}; volatile uint32_t commit_micros{0}; volatile uint32_t prev_micros{0}; - uint32_t buffer_size{1000}; + uint32_t buffer_entries{0}; uint32_t filter_us{10}; uint32_t idle_us{10000}; ISRInternalGPIOPin pin; diff --git a/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml new file mode 100644 index 00000000000..c9c95ed05c3 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7252 + +remote_receiver: + - id: rcvr + pin: P6 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml new file mode 100644 index 00000000000..e8930d4e17e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c61-devkitc1 + variant: esp32c61 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml new file mode 100644 index 00000000000..8767b546e62 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h + +remote_receiver: + - id: rcvr + pin: PA4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rp2.yaml b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml new file mode 100644 index 00000000000..cfc66786ba2 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rp2: + board: rpipicow + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml new file mode 100644 index 00000000000..113bece34c5 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +remote_receiver: + - id: rcvr + pin: PA12 diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py index 9bfd12d9f55..cc4ea49ccb8 100644 --- a/tests/component_tests/remote_receiver/test_buffer_size.py +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -1,8 +1,16 @@ -"""buffer_size reaches the receiver when set, and always on the pulse ring targets.""" +"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set.""" from collections.abc import Callable from pathlib import Path +import pytest + +from esphome.components import remote_receiver +from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + def test_explicit_buffer_size_is_passed_through( generate_main: Callable[[str | Path], str], @@ -12,17 +20,29 @@ def test_explicit_buffer_size_is_passed_through( assert "rcvr->set_buffer_size(2000);" in main_cpp -def test_pulse_ring_target_keeps_a_default( +@pytest.mark.parametrize( + "target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"] +) +def test_pulse_ring_default_holds_1000_pulses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], + target: str, ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp8266.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml")) + assert "rcvr->set_buffer_size(4000);" in main_cpp -def test_esp32_variant_without_rmt_keeps_a_default( - generate_main: Callable[[str | Path], str], - component_config_path: Callable[[str], Path], +@pytest.mark.parametrize( + ("value", "expected"), + [("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)], +) +def test_buffer_size_range( + set_core_config: SetCoreConfigCallable, value: str, expected: int | None ) -> None: - main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml")) - assert "rcvr->set_buffer_size(1000);" in main_cpp + set_core_config(PlatformFramework.ESP8266_ARDUINO) + config = {"pin": "GPIO4", "buffer_size": value} + if expected is None: + with pytest.raises(Invalid): + remote_receiver.CONFIG_SCHEMA(config) + else: + assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected From 2a8d6b69cd0dcbe497e1ed4b8576a3a0710cb4a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:16:41 -0500 Subject: [PATCH 300/759] [rp2] Bump arduino-pico framework to 6.1.0 (#19261) --- .../bluetooth_connection_rp2.cpp | 2 +- esphome/components/rp2/__init__.py | 12 ++++++------ esphome/components/rp2/boards.py | 18 ++++++++++++++++++ .../components/rp2040_ble/btstack_memory.cpp | 2 +- esphome/core/defines.h | 2 +- platformio.ini | 4 ++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd1..eec2c8c3186 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { // explicit kick the MTU would only be exchanged on the first GATT query, // which never happens on a V3_WITH_CACHE connection. // Both registration calls above return void (BTstack 075a078, arduino-pico - // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by // the connect timeout in loop(). gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); } diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index dae7df26c32..a1bbf6a3d66 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,20 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain -# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" +# develop-branch commit carrying the arduino-pico 6.1.0 update and the board +# JSON files it adds; replace with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 0, 0), None), + "dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 0), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b01..a9ce11c33dc 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "soldered_nula_node_rp2040": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -2127,6 +2139,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_node_rp2040": { + "name": "Soldered Electronics NULA Node", + "mcu": "rp2040", + "max_pin": 29, + "wifi": True, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 8af57924a2b..699555f623f 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bc1418c6e2b..fb76f90bcf3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -537,7 +537,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/platformio.ini b/platformio.ini index 722109adec4..37504384cbc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -203,11 +203,11 @@ extra_scripts = extends = common:arduino board_build.filesystem_size = 0.5m -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1 platform_packages = ; The framework-arduinopico package is no longer published to the PlatformIO ; registry, so install the framework straight from the GitHub release - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip framework = arduino lib_deps = From 508c24c5e3c6394a3bda02aa4711b3225bc5f57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:11 -0500 Subject: [PATCH 301/759] [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) --- esphome/components/esp32/crash_handler.cpp | 14 +++++++++----- esphome/components/esp32/crash_handler.h | 7 +------ esphome/components/esp32/hal.cpp | 8 -------- esphome/components/rp2/crash_handler.cpp | 22 +++++++++++++++++----- esphome/components/rp2/crash_handler.h | 3 ++- esphome/core/application.h | 4 ++-- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa0..b72a2777c7a 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ece..314be80314c 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f3..199cb89f516 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea216371..9bcdc8bee4e 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b0..3aec80b63b2 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/core/application.h b/esphome/core/application.h index f1cf6fcca02..8ed4c09096a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); From 05bba3fdc262a01b5e5932f39e0a7cd52d696d4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:28 -0500 Subject: [PATCH 302/759] [image] Blend grayscale alpha with Color::gradient (#19356) --- esphome/components/image/image.cpp | 12 +++++------- esphome/core/color.cpp | 15 +++++---------- esphome/core/color.h | 12 +++++++++--- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683abc..bfe311be284 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color continue; // skip drawing } break; - case TRANSPARENCY_ALPHA_CHANNEL: { - auto on = (float) gray / 255.0f; - auto off = 1.0f - on; - // blend color_on and color_off - color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off, - color_on.b * on + color_off.b * off, 0xFF); + case TRANSPARENCY_ALPHA_CHANNEL: + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); break; - } default: break; } diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc7714720..ba8a594340f 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,18 +6,13 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); -Color Color::gradient(const Color &to_color, uint8_t amnt) { - uint8_t inv = 255 - amnt; - Color new_color; - new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255; - new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255; - new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255; - new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255; - return new_color; +Color Color::gradient(const Color &to_color, uint8_t amnt) const { + return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt), + blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt)); } -Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } +Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); } -Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } +Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); } } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623df..c7fd522e1a5 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,9 +174,15 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt); - Color fade_to_white(uint8_t amnt); - Color fade_to_black(uint8_t amnt); + /// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a + /// per pixel loop can blend without a call; gradient() itself stays out of + /// line so the light effects and fade_to_*() share one copy. + static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE { + return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255; + } + Color gradient(const Color &to_color, uint8_t amnt) const; + Color fade_to_white(uint8_t amnt) const; + Color fade_to_black(uint8_t amnt) const; Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } From 40934484c6d589c5ae29a980f00aaf9a95ae59cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:34:36 -0500 Subject: [PATCH 303/759] [output] Use BinaryOutput::set_state() instead of hand written turn_on/turn_off branches (#19366) --- esphome/components/binary/light/binary_light_output.h | 6 +----- esphome/components/improv_ble/improv_ble_component.cpp | 6 +----- esphome/components/mcp4461/output/mcp4461_output.cpp | 8 -------- esphome/components/mcp4461/output/mcp4461_output.h | 3 --- esphome/components/output/switch/output_switch.cpp | 6 +----- 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c8..b8de7932cd6 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput { void write_state(light::LightState *state) override { bool binary; state->current_values_as_binary(&binary); - if (binary) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(binary); } protected: diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index bbc1589abf0..0a20beb33c8 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -208,11 +208,7 @@ void ImprovBLEComponent::set_status_indicator_state_(bool state) { if (this->status_indicator_state_ == state) return; this->status_indicator_state_ = state; - if (state) { - this->status_indicator_->turn_on(); - } else { - this->status_indicator_->turn_off(); - } + this->status_indicator_->set_state(state); #endif } diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d2..d38eed4d096 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() { return this->state_; } -void Mcp4461Wiper::set_state(bool state) { - if (state) { - this->turn_on(); - } else { - this->turn_off(); - } -} - void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); } void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec51..1052369a744 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedcontrol(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { - if (state) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(state); this->publish_state(state); } From 28b2a689a722313715664936fd7fcc3424f7e29f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:02 -0500 Subject: [PATCH 304/759] [web_server] Reduce flash used by the JSON and request helpers (#19303) --- esphome/components/json/json_util.cpp | 2 + esphome/components/json/json_util.h | 3 + esphome/components/web_server/web_server.cpp | 68 +++++++++++-------- esphome/components/web_server/web_server.h | 2 +- .../web_server_idf/web_server_idf.h | 4 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f9..1b1eefe59b3 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() = default; + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b8..130e1503321 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) { /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); + JsonObject root() { if (!root_created_) { root_ = doc_.to(); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1683492da76..8b0dfe166fb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -66,9 +66,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } + json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); @@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } @@ -382,7 +390,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } @@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d60b39278aa..7aa4ac24a3b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c5648..743d296d73a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; public: - AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. From 271f85185d5138ed48ef0d113ab7676ae7e30e68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:15 -0500 Subject: [PATCH 305/759] [sensor] Remove deprecated raw_state member (#19077) --- .../number/modbus_number.cpp | 1 - .../sensor/modbus_sensor.cpp | 1 - esphome/components/sensor/sensor.cpp | 10 +- esphome/components/sensor/sensor.h | 24 ++-- .../fixtures/sensor_raw_state.yaml | 53 +++++++++ .../fixtures/sensor_raw_state_no_filter.yaml | 31 +++++ tests/integration/test_sensor_raw_state.py | 108 ++++++++++++++++++ 7 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 tests/integration/fixtures/sensor_raw_state.yaml create mode 100644 tests/integration/fixtures/sensor_raw_state_no_filter.yaml create mode 100644 tests/integration/test_sensor_raw_state.py diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517a..223aa12bec2 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd04..2035f2220a3 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b1..bee5d7c6d33 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(state_class), 0); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -Sensor::Sensor() : state(NAN), raw_state(NAN) {} -#pragma GCC diagnostic pop +Sensor::Sensor() : state(NAN) {} int8_t Sensor::get_accuracy_decimals() { if (this->sensor_flags_.has_accuracy_override) @@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() { } void Sensor::publish_state(float state) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->raw_state = state; -#pragma GCC diagnostic pop #ifdef USE_SENSOR_FILTER + this->raw_state_ = state; this->raw_callback_.call(state); #endif diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af9851..20288fa88e0 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -96,18 +96,20 @@ class Sensor : public EntityBase { /// Getter-syntax for .state. float get_state() const { return this->state; } - /// Getter-syntax for .raw_state + /// Get the last state received by publish_state(), before any filters were applied. float get_raw_state() const { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop +#ifdef USE_SENSOR_FILTER + return this->raw_state_; +#else + return this->state; // No filters compiled in, raw == filtered +#endif } /** Publish a new state to the front-end. * - * First, the new state will be assigned to the raw_value. Then it's passed through all filters - * until it finally lands in the .value member variable and a callback is issued. + * The value is passed through the filter chain (when filters are compiled in) before landing in + * the `state` member and triggering the state callback. The pre-filter value is available via + * get_raw_state(). * * @param state The state as a floating point number. */ @@ -137,17 +139,11 @@ class Sensor : public EntityBase { */ float state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0") - float raw_state; -#pragma GCC diagnostic pop - void internal_send_state_to_frontend(float state); protected: #ifdef USE_SENSOR_FILTER + float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/tests/integration/fixtures/sensor_raw_state.yaml b/tests/integration/fixtures/sensor_raw_state.yaml new file mode 100644 index 00000000000..9c19032028f --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state.yaml @@ -0,0 +1,53 @@ +esphome: + name: test-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists +sensor: + # No filters on this sensor: get_raw_state() must equal state + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + + # Filtered sensor: get_raw_state() must be the pre-filter value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + accuracy_decimals: 1 + filters: + - multiply: 2.0 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - sensor.template.publish: + id: with_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "WITH_FILTER: state=%.1f raw_state=%.1f" + args: + - id(with_filter_sensor).state + - id(with_filter_sensor).get_raw_state() diff --git a/tests/integration/fixtures/sensor_raw_state_no_filter.yaml b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml new file mode 100644 index 00000000000..fec912691f2 --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml @@ -0,0 +1,31 @@ +esphome: + name: test-sensor-raw-state-no-filter + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and +# get_raw_state() falls back to state +sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + accuracy_decimals: 1 + +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - sensor.template.publish: + id: no_filter_sensor + state: 21.5 + - delay: 50ms + - logger.log: + format: "NO_FILTER: state=%.1f raw_state=%.1f" + args: + - id(no_filter_sensor).state + - id(no_filter_sensor).get_raw_state() diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py new file mode 100644 index 00000000000..a178ebf7d4c --- /dev/null +++ b/tests/integration/test_sensor_raw_state.py @@ -0,0 +1,108 @@ +"""Integration tests for Sensor::get_raw_state(). + +Raw state storage only exists when filters are compiled in (USE_SENSOR_FILTER). +Without it, get_raw_state() returns state, so both build configurations are covered: +one fixture with a filtered sensor and one with no filters at all. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)") +WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)") + + +async def _press_and_read( + client: APIClient, + entities: list[EntityInfo], + button_object_id: str, + future: asyncio.Future[tuple[float, float]], + label: str, +) -> tuple[float, float]: + button = next( + (e for e in entities if button_object_id in e.object_id.lower()), None + ) + assert button is not None, f"{button_object_id} not found" + client.button_command(button.key) + try: + return await asyncio.wait_for(future, timeout=5.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {label} log message") + + +@pytest.mark.asyncio +async def test_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With filters compiled in, raw state is stored separately from state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + if not with_filter_future.done() and ( + match := WITH_FILTER_PATTERN.search(line) + ): + with_filter_future.set_result( + (float(match.group(1)), float(match.group(2))) + ) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 + + state, raw_state = await _press_and_read( + client, + entities, + "test_with_filter_button", + with_filter_future, + "WITH_FILTER", + ) + assert state == 43.0 + assert raw_state == 21.5 + + +@pytest.mark.asyncio +async def test_sensor_raw_state_no_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Without filters compiled in, get_raw_state() returns state.""" + loop = asyncio.get_running_loop() + no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future() + + def check_output(line: str) -> None: + if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)): + no_filter_future.set_result((float(match.group(1)), float(match.group(2)))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + state, raw_state = await _press_and_read( + client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER" + ) + assert state == 21.5 + assert raw_state == 21.5 From 7d17efc15f1c41792af68f495077c7277f995e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:46 -0500 Subject: [PATCH 306/759] [espidf] Emit json2 size data so the link edge is not blocked (#18848) --- esphome/build_gen/espidf.py | 13 +- esphome/espidf/size_summary.py | 101 ++++++-- esphome/espidf/toolchain.py | 15 +- tests/unit_tests/build_gen/test_espidf.py | 12 + tests/unit_tests/test_espidf_toolchain.py | 37 +++ tests/unit_tests/test_size_summary.py | 298 +++++++++++++++++----- 6 files changed, 388 insertions(+), 88 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 2ef89cf595b..7689fc93b0e 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -90,9 +90,10 @@ def get_project_cmakelists( """ idf_target = variant_to_idf_target(get_esp32_variant()) - # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and - # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get - # --format=raw because the legacy mode doesn't support it. + # esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng; + # 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks + # total_size, hence the ELF fallback in espidf/size_summary.py; both + # go away together when 1.x support is dropped. size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" # Project-wide compile options: -D defines and -W warning flags (skip @@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) -# Emit raw JSON size data for ESPHome to read post-build. +# Emit per-memory-type JSON size data for ESPHome to read post-build. +# json2 stays small; raw dumps every symbol (~2s on a large map) and +# this command runs inside the link edge, blocking everything downstream. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 2be3634c693..d98363dd67e 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -9,16 +9,19 @@ byte-identical to PlatformIO's output: Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes) The format matches ``script/ci_memory_impact_extract.py`` so CI memory -analysis works unchanged on native ESP-IDF builds. RAM total is the -DRAM region size from the linker map; Flash total is taken from +analysis works unchanged on native ESP-IDF builds. RAM usage comes from +the DRAM (or unified DIRAM) region of the linker map. Flash used is the +exact image size matching the ``Total image size`` line: json2 +``total_size`` when present, otherwise derived from the ELF (see +``_image_size_from_elf``). Flash total is taken from ``partitions.csv`` using PlatformIO's rule (first app partition whose subtype is ``factory`` or ``ota_0``; see ``platform-espressif32/builder/main.py::_update_max_upload_size``). Structured size data is produced at link time by a CMake POST_BUILD custom command (see ``build_gen/espidf.py``) which writes -``esp_idf_size.json`` next to the ELF. We read that file here rather -than re-running ``esp_idf_size`` from Python. +``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary) +next to the ELF; we read that rather than re-running ``esp_idf_size``. """ from __future__ import annotations @@ -27,6 +30,7 @@ import csv import json import logging from pathlib import Path +import struct from esphome.build_helpers.size_summary import print_size_line @@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int: raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") -def print_summary(size_json: Path, partitions_csv: Path | None) -> None: +def _image_size_from_elf(elf: Path) -> int: + """Sum the allocated PROGBITS section sizes from an ELF32 file. + + Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte; + esptool's ``ELFFile`` filters sections differently and would not. + Raises ``ValueError`` for anything but a well-formed ELF32 LE file. + """ + with elf.open("rb") as f: + header = f.read(52) # ELF32 header + if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(f"{elf} is not a 32-bit little-endian ELF") + (e_shoff,) = struct.unpack_from(" None: """Print PlatformIO-shaped RAM and Flash one-liners. Failures are non-fatal: the build has already succeeded, we just couldn't - summarize. Logs the cause at debug level. + summarize. Anomalies (missing region, unreadable ELF) warn; expected + optional inputs (no size json, no partitions.csv) log at debug. """ if not size_json.is_file(): _LOGGER.debug("Skipping size summary: %s not found", size_json) @@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Skipping size summary: %s", e) return - - memory_types = data.get("memory_types", {}) - ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {} - ram_used = ram_region.get("used") - ram_total = ram_region.get("size") - if ram_total and ram_used is not None: - print_size_line("RAM", ram_used, ram_total) - - image_size = data.get("image_size") - if image_size is None or partitions_csv is None: + if not isinstance(data, dict): + _LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json) return + + layout = data.get("layout") + regions = { + entry.get("name"): entry + for entry in (layout if isinstance(layout, list) else []) + if isinstance(entry, dict) + } + # Every chip has a DRAM or DIRAM region, so a warning here usually + # means the esp_idf_size json schema changed + ram_region = regions.get("DRAM") or regions.get("DIRAM") + if ram_region is None: + _LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json) + elif ( + isinstance(ram_total := ram_region.get("total"), int) + and ram_total > 0 + and isinstance(ram_used := ram_region.get("used"), int) + ): + print_size_line("RAM", ram_used, ram_total) + else: + _LOGGER.warning( + "Skipping RAM summary: unusable region %s in %s", ram_region, size_json + ) + + # esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in + # json2; older 1.x omits it, so derive the same figure from the ELF. + flash_used = data.get("total_size") + if not (isinstance(flash_used, int) and flash_used > 0): + _LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf) + try: + flash_used = _image_size_from_elf(firmware_elf) + except (OSError, ValueError) as e: + # The ELF must be present and well formed after a successful build + _LOGGER.warning("Skipping Flash summary: %s", e) + return try: app_size = _find_app_partition_size(partitions_csv) - except ValueError as e: + except (OSError, ValueError) as e: _LOGGER.debug("Skipping Flash summary: %s", e) return - print_size_line("Flash", image_size, app_size) + if app_size <= 0: + _LOGGER.debug("Skipping Flash summary: app partition size is 0") + return + print_size_line("Flash", flash_used, app_size) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 986f9dfb8bd..f695bdb7ab4 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int: if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") - print_summary(size_json, partitions if partitions.is_file() else None) + print_summary(size_json, partitions, get_built_elf_path()) return rc @@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path: return build_dir / "firmware.ota.bin" +def get_built_elf_path() -> Path: + """Path to the ELF idf.py writes directly, ``/.elf``. + + Exists as soon as the build finishes, unlike the ``firmware.elf`` + copy that ``create_elf_copy`` makes later. + """ + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.elf" + + def get_elf_path() -> Path: """Get the path to the firmware ELF file. @@ -706,8 +716,7 @@ def create_elf_copy() -> bool: "download ELF" link requests the literal filename ``firmware.elf`` (PlatformIO convention), so copy it to that name. """ - build_dir = CORE.relative_build_path("build") - src_elf = build_dir / f"{CORE.name}.elf" + src_elf = get_built_elf_path() dst_elf = get_elf_path() if not src_elf.is_file(): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 079f10ddb91..2848d7202df 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -163,6 +163,18 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None: assert has_discovered_components() +def test_get_project_cmakelists_size_command_uses_json2() -> None: + """The POST_BUILD size command uses the cheap json2 format, with --ng + only on the 1.x tool bundled with IDF < 6.""" + content = _render() + assert "-m esp_idf_size --ng --format=json2" in content + + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0) + content = _render() + assert "--ng" not in content + assert "--format=json2" in content + + def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: """A cached list replaces project_description.json and is still filtered by EXCLUDE_COMPONENTS.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 9deb27d83cb..bb2aab17a24 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -638,6 +638,43 @@ def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None: mock_run.assert_called_once_with("build", "size", jobs=1) +def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: + """print_summary receives the size json, partitions.csv, and the built + ELF from get_built_elf_path, which must stay in lockstep with the + project() name in the generated CMakeLists.""" + _setup_build(setup_core) + config = {CONF_ESPHOME: {}} + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "run_idf_py", return_value=0), + patch.object(toolchain, "print_summary") as mock_summary, + ): + assert toolchain.run_compile(config, verbose=False) == 0 + + mock_summary.assert_called_once_with( + CORE.relative_build_path("build", "esp_idf_size.json"), + CORE.relative_build_path("partitions.csv"), + CORE.relative_build_path("build", f"{CORE.name}.elf"), + ) + + +def test_create_elf_copy(setup_core: Path) -> None: + """The built .elf is copied to the firmware.elf dashboard name.""" + _setup_build(setup_core) + src = toolchain.get_built_elf_path() + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(b"elf") + assert toolchain.create_elf_copy() is True + assert toolchain.get_elf_path().read_bytes() == b"elf" + + +def test_create_elf_copy_missing_source(setup_core: Path) -> None: + """A missing built ELF is a warning and False, not a crash.""" + _setup_build(setup_core) + assert toolchain.create_elf_copy() is False + + def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: """When no compile_process_limit is set, no job limit is passed to idf.py.""" _setup_build(setup_core) diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 0c0852a191e..245184f2d03 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -4,6 +4,8 @@ from __future__ import annotations import json from pathlib import Path +import struct +from unittest.mock import patch import pytest @@ -17,64 +19,106 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path: return out +def _write_partitions(tmp_path: Path) -> Path: + """Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot.""" + out = tmp_path / "partitions.csv" + out.write_text( + "# name, type, subtype, offset, size, flags\n" + "app0, app, ota_0, 0x10000, 0x1C0000,\n" + ) + return out + + +def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes: + """Build a minimal ELF32 LE whose section headers carry the given + (sh_type, sh_flags, sh_size) triples.""" + out = bytearray(52) + out[0:4] = b"\x7fELF" + out[4] = out[5] = 1 # 32-bit, little-endian + struct.pack_into(" dict: - """Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM).""" + """Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the + esp-idf-size >= 2.1 shape that carries ``total_size``.""" return { - "image_size": 827455, - "memory_types": { - "DRAM": { - "size": 180736, + "version": "1.1", + "total_size": 827455, + "layout": [ + { + "name": "DRAM", + "total": 180736, "used": 47332, - "sections": { - ".dram0.bss": {"abbrev_name": ".bss", "size": 30616}, - ".dram0.data": {"abbrev_name": ".data", "size": 16716}, + "free": 133404, + "parts": { + ".bss": {"size": 30616}, + ".data": {"size": 16716}, }, }, - "IRAM": { - "size": 131072, + { + "name": "IRAM", + "total": 131072, "used": 80351, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 79323}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 50721, + "parts": { + ".text": {"size": 79323}, + ".vectors": {"size": 1028}, }, }, - }, + ], } def _s3_size_data() -> dict: - """Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM).""" + """Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x + shape without ``total_size``.""" return { - "image_size": 724215, - "memory_types": { - "DIRAM": { - "size": 341760, + "version": "1.1", + "layout": [ + { + "name": "DIRAM", + "total": 341760, "used": 104999, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 58051}, - ".dram0.bss": {"abbrev_name": ".bss", "size": 27088}, - ".dram0.data": {"abbrev_name": ".data", "size": 19708}, - ".noinit": {"abbrev_name": ".noinit", "size": 152}, + "free": 236761, + "parts": { + ".text": {"size": 58051}, + ".bss": {"size": 27088}, + ".data": {"size": 19708}, + ".noinit": {"size": 152}, }, }, - "IRAM": { - "size": 16384, + { + "name": "IRAM", + "total": 16384, "used": 16384, - "sections": { - ".iram0.text": {"abbrev_name": ".text", "size": 15356}, - ".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028}, + "free": 0, + "parts": { + ".text": {"size": 15356}, + ".vectors": {"size": 1028}, }, }, - }, + ], } +def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None: + """Call print_summary with no partitions.csv or ELF on disk.""" + print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf") + + def test_print_summary_esp32_uses_dram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged.""" + """Original ESP32: RAM = DRAM.used / DRAM.total.""" size_json = _write_size_json(tmp_path, _esp32_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" in out assert "used 47332 bytes from 180736 bytes" in out @@ -83,63 +127,193 @@ def test_print_summary_esp32_uses_dram( def test_print_summary_s3_falls_back_to_diram( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - """ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage.""" + """ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage.""" size_json = _write_size_json(tmp_path, _s3_size_data()) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "used 104999 bytes from 341760 bytes" in out def test_print_summary_skips_when_diram_total_collapses( - tmp_path: Path, capsys: pytest.CaptureFixture[str] + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A zero-size region drops the RAM line rather than divide by zero.""" size_json = _write_size_json( tmp_path, { - "memory_types": { - "DIRAM": { - "size": 0, - "used": 0, - "sections": {}, - }, - }, + "version": "1.1", + "layout": [{"name": "DIRAM", "total": 0, "used": 0}], }, ) - print_summary(size_json, partitions_csv=None) + _print_summary_ram_only(tmp_path, size_json) out = capsys.readouterr().out assert "RAM:" not in out + assert "unusable region" in caplog.text def test_print_summary_handles_missing_json( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """Missing size json is non-fatal and prints nothing.""" - print_summary(tmp_path / "does_not_exist.json", partitions_csv=None) + _print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json") assert capsys.readouterr().out == "" -def test_print_summary_handles_no_memory_types( - tmp_path: Path, capsys: pytest.CaptureFixture[str] +def test_print_summary_handles_no_layout( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: - """A size json without ``memory_types`` still doesn't crash.""" - size_json = _write_size_json(tmp_path, {"image_size": 0}) - print_summary(size_json, partitions_csv=None) + """A size json without ``layout`` warns so schema drift is visible.""" + size_json = _write_size_json(tmp_path, {"version": "1.1"}) + _print_summary_ram_only(tmp_path, size_json) assert capsys.readouterr().out == "" - - -def test_print_summary_flash_line( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - """A partition table with an app row yields the Flash line in the exact - padded shape script/ci_memory_impact_extract.py greps.""" - size_json = _write_size_json(tmp_path, _esp32_size_data()) - partitions = tmp_path / "partitions.csv" - partitions.write_text( - "# name, type, subtype, offset, size, flags\n" - "app0, app, ota_0, 0x10000, 0x1C0000,\n" + assert any( + r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message + for r in caplog.records ) - print_summary(size_json, partitions) + + +def test_print_summary_flash_line_prefers_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """With ``total_size`` in the json, that figure wins without reading the + ELF, in the exact shape script/ci_memory_impact_extract.py greps.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + print_summary(size_json, partitions, tmp_path / "firmware.elf") out = capsys.readouterr().out assert "Flash: " in out assert "(used 827455 bytes from 1835008 bytes)" in out + + +def test_print_summary_flash_line_derives_from_elf( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS + sections; NOBITS and non-alloc sections are excluded.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes( + _elf_bytes( + [ + (1, 0x6, 700000), # PROGBITS, alloc+exec: counted + (1, 0x2, 24215), # PROGBITS, alloc: counted + (8, 0x2, 50000), # NOBITS (.bss): excluded + (1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded + ] + ) + ) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 724215 bytes from 1835008 bytes)" in out + + +@pytest.mark.parametrize( + "data", + [ + pytest.param([1, 2], id="top_level_list"), + pytest.param({"version": "1.1", "layout": None}, id="layout_null"), + pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"), + ], +) +def test_print_summary_handles_unexpected_shapes( + data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A foreign-schema size json degrades to a warning, never a traceback.""" + size_json = _write_size_json(tmp_path, data) + _print_summary_ram_only(tmp_path, size_json) + assert capsys.readouterr().out == "" + + +def test_print_summary_skips_flash_on_zero_app_partition( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero-size app partition skips the Flash line rather than printing + a from-0-bytes figure CI would record.""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n" + ) + print_summary(size_json, partitions, tmp_path / "firmware.elf") + out = capsys.readouterr().out + assert "Flash:" not in out + + +def test_print_summary_skips_flash_on_unreadable_partitions( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An unreadable partitions.csv is non-fatal (chmod tricks don't work + for root in CI containers, so simulate the OSError instead).""" + size_json = _write_size_json(tmp_path, _esp32_size_data()) + partitions = _write_partitions(tmp_path) + with patch( + "esphome.espidf.size_summary._find_app_partition_size", + side_effect=PermissionError("denied"), + ): + print_summary(size_json, partitions, tmp_path / "firmware.elf") + assert "Flash:" not in capsys.readouterr().out + + +def test_print_summary_flash_falls_back_on_bad_total_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A zero or non-int total_size falls back to the ELF instead of + printing a used-0-bytes line CI would read as a real measurement.""" + data = _s3_size_data() + data["total_size"] = 0 + size_json = _write_size_json(tmp_path, data) + partitions = _write_partitions(tmp_path) + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)])) + print_summary(size_json, partitions, firmware_elf) + out = capsys.readouterr().out + assert "(used 4096 bytes from 1835008 bytes)" in out + + +_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)]) + + +@pytest.mark.parametrize( + ("elf_bytes", "with_partitions"), + [ + pytest.param(None, True, id="missing_elf"), + pytest.param(b"junk", True, id="not_an_elf"), + pytest.param( + _elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize" + ), + pytest.param(_GOOD_ELF[:60], True, id="truncated_table"), + pytest.param(_elf_bytes([]), True, id="no_sections"), + pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"), + pytest.param(_GOOD_ELF, False, id="missing_partitions"), + ], +) +def test_print_summary_skips_flash_on_bad_input( + elf_bytes: bytes | None, + with_partitions: bool, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line.""" + size_json = _write_size_json(tmp_path, _s3_size_data()) + firmware_elf = tmp_path / "firmware.elf" + if elf_bytes is not None: + firmware_elf.write_bytes(elf_bytes) + if with_partitions: + _write_partitions(tmp_path) + print_summary(size_json, tmp_path / "partitions.csv", firmware_elf) + out = capsys.readouterr().out + assert "RAM:" in out + assert "Flash:" not in out + # ELF problems warn (anomaly after a successful build); a missing + # partitions.csv stays at debug + warned = any( + r.levelname == "WARNING" and "Skipping Flash summary" in r.message + for r in caplog.records + ) + assert warned == with_partitions From 8ac3dba11ac3669961ad833b324c1c1142463d38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:40:30 -0500 Subject: [PATCH 307/759] [esp32] Add a flash chip option that drops the unused flash vendor drivers (#19217) --- esphome/components/esp32/__init__.py | 42 +++++++++++ esphome/core/application.cpp | 34 ++++++++- .../esp32/config/flash_chip_gd.yaml | 9 +++ .../esp32/config/flash_chip_generic.yaml | 9 +++ .../esp32/config/flash_chip_mxic_opi_s3.yaml | 10 +++ tests/component_tests/esp32/test_esp32.py | 73 +++++++++++++++++++ tests/components/esp32/test.esp32-idf.yaml | 1 + tests/components/esp32/test.esp32-s3-idf.yaml | 1 + 8 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/esp32/config/flash_chip_gd.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_generic.yaml create mode 100644 tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0c5b9c5df6a..748be9ae4cf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -111,6 +111,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_NVS_ENCRYPTION = "nvs_encryption" @@ -464,6 +465,20 @@ ESP32_CHIP_REVISIONS = { "3.1": "CONFIG_ESP32_REV_MIN_3_1", } +# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM +# and only the one matching the flash ID is ever used +ESP32_FLASH_CHIPS = { + "gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", + "issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP", + "mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP", + "winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP", + "boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP", + "th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP", + "mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", +} +FLASH_CHIP_GENERIC = "generic" +FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only + # Socket limit configuration for ESP-IDF # ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10 DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default @@ -1533,6 +1548,25 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM], ) ) + if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None: + opi = flash_chip == FLASH_CHIP_OPI + if opi and config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) + elif opi != (config.get(CONF_FLASH_MODE) == "opi"): + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'" + if opi + else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match " + f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32P4 and config.get(CONF_ENGINEERING_SAMPLE) is not None @@ -1971,6 +2005,9 @@ FRAMEWORK_SCHEMA = cv.Schema( *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, + cv.Optional(CONF_FLASH_CHIP): cv.one_of( + FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True + ), # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -2765,6 +2802,11 @@ async def to_code(config): add_idf_sdkconfig_option(flag, rev == min_rev) cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET") + # Keep only the flash vendor driver the board needs; the boot log names it + if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None: + for chip, flag in ESP32_FLASH_CHIPS.items(): + add_idf_sdkconfig_option(flag, chip == flash_chip) + # Use SRAM1 region as IRAM on ESP32 (original) variant # This provides an additional 40KB of IRAM by using SRAM1 memory that was previously # reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 38d3503c2c3..50d1c619595 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#endif +// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all +#define ESPHOME_FLASH_VENDOR_DRIVERS \ + (CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP) #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -157,8 +170,25 @@ void Application::process_dump_config_() { esp_chip_info(&chip_info); ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); -#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM)) - static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; + [[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 0 + { + // Only the driver in use earns its IRAM; with several linked at least one is idle + const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 1 + constexpr bool idle_driver = true; +#else + const bool idle_driver = flash_driver == &esp_flash_chip_generic; +#endif + if (idle_driver) { + const char *value = flash_driver->name; +#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP + if (flash_driver == &esp_flash_chip_mxic_opi) + value = "mxic_opi"; +#endif + ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH); + } + } #endif #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) { diff --git a/tests/component_tests/esp32/config/flash_chip_gd.yaml b/tests/component_tests/esp32/config/flash_chip_gd.yaml new file mode 100644 index 00000000000..6d564135c00 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_gd.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: gd diff --git a/tests/component_tests/esp32/config/flash_chip_generic.yaml b/tests/component_tests/esp32/config/flash_chip_generic.yaml new file mode 100644 index 00000000000..8c7bcf61664 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_generic.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: generic diff --git a/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml new file mode 100644 index 00000000000..1531e749f20 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf + advanced: + flash_chip: mxic_opi diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index d5d0acfb2ad..a42d244ac8f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,6 +10,7 @@ from typing import Any import pytest from esphome.components.esp32 import ( + ESP32_FLASH_CHIPS, KEY_FATFS_REQUIRED, KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, KEY_MBEDTLS_TLS_SERVER_REQUIRED, @@ -252,6 +253,41 @@ def test_esp32_rejects_unsupported_cli_toolchain( r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", id="nvs_encryption_key_id_out_of_range", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_only_on_s3", + ), + pytest.param( + { + "variant": "esp32s3", + "flash_mode": "opi", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "gd"}, + }, + }, + r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_must_match_opi_mode", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"flash_chip": "mxic_opi"}, + }, + }, + r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]", + id="flash_chip_mxic_opi_requires_opi_mode", + ), pytest.param( { "variant": "esp32", @@ -719,6 +755,43 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" +@pytest.mark.parametrize( + ("config_file", "enabled"), + [ + pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"), + pytest.param("flash_chip_generic.yaml", None, id="generic"), + pytest.param( + "flash_chip_mxic_opi_s3.yaml", + "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", + id="mxic_opi_s3", + ), + ], +) +def test_flash_chip_keeps_one_vendor_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + enabled: str | None, +) -> None: + """flash_chip enables only the chosen vendor driver.""" + generate_main(component_config_path(config_file)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + vendors = { + k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_") + } + assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()} + + +def test_flash_chip_unset_keeps_idf_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_chip every vendor driver stays at its ESP-IDF default.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig) + + def test_flash_mode_opi_enables_octal_flash( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 7f31fe59c6f..5c7cb1d61b8 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -22,6 +22,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + flash_chip: gd watchdog_timeout: 7s wifi: diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index b9a3b804a8d..5bdf94e8e1d 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -9,6 +9,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + flash_chip: gd disable_libc_locks_in_iram: true # Test default RAM optimization enabled disable_debug_stubs: true disable_ocd_aware: true From 91ab2290862f15d1a7144c4d457c9c866c1bf2d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:17:08 -0500 Subject: [PATCH 308/759] [core] Lint bare string literal ternaries in ESP_LOG arguments (#18908) --- esphome/components/snapshot/snapshot.cpp | 2 +- script/ci-custom.py | 146 ++++++++++++++++++++++- tests/script/test_ci_custom.py | 126 +++++++++++++++++++ 3 files changed, 267 insertions(+), 7 deletions(-) diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 995f87710e9..5aa0bcd0e13 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -211,7 +211,7 @@ bool Snapshot::take_snapshot(const char *filename) { exact = !name.empty(); if (name_changed) { ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, - name.empty() ? "a name made from the time" : name.c_str()); + name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); } } if (name.empty()) { diff --git a/script/ci-custom.py b/script/ci-custom.py index 286dda85b9e..6672760b06d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -3,6 +3,7 @@ import argparse import codecs import collections +from collections.abc import Iterator import fnmatch import functools import os.path @@ -1138,7 +1139,56 @@ def lint_no_std_bind(fname, match): ) -LOG_MULTILINE_RE = re.compile(r"ESP_LOG\w+\s*\(.*?;", re.DOTALL) +LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") +# Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : +# inside them are never seen. A char literal is exactly one (escaped) char so a digit separator +# like 1'000'000 cannot open one. +CPP_COMMENT_RE = r"//[^\n]*|/\*.*?\*/" +CPP_SKIP_RE = ( + CPP_COMMENT_RE + + r'|R"(?P[^(\s]*)\(.*?\)(?P=raw_delim)"|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\\n]|\\.)\'' +) +LOG_CALL_TOKEN_RE = re.compile(CPP_SKIP_RE + r"|[()]", re.DOTALL) +# The last alternative matches a ? or : followed (after spaces or comments) by an opening quote, +# i.e. a string literal used as a ternary branch. +LOG_TERNARY_LITERAL_RE = re.compile( + CPP_SKIP_RE + r"|[?:](?:\s|" + CPP_COMMENT_RE + r')*(?=")', re.DOTALL +) +# A bare NOLINT; a clang-tidy NOLINT(check-name) is aimed at a different tool. +NOLINT_RE = re.compile(r"\bNOLINT\b(?!\()") + + +def _line_col(content: str, pos: int) -> tuple[int, int]: + """1-based line and column of an offset in content.""" + return content.count("\n", 0, pos) + 1, pos - content.rfind("\n", 0, pos) + + +def _iter_log_calls(content: str) -> Iterator[tuple[int, str | None]]: + """Yield (start, text) for every ESP_LOG*(...) call, text running to the matching close paren. + text is None when no matching paren exists so callers can report the call instead of skipping it.""" + for head in LOG_CALL_START_RE.finditer(content): + depth = 1 + for tok in LOG_CALL_TOKEN_RE.finditer(content, head.end()): + if tok.group(0) == "(": + depth += 1 + elif tok.group(0) == ")": + depth -= 1 + if depth == 0: + yield head.start(), content[head.start() : tok.end()] + break + else: + yield head.start(), None + + +def _unbalanced_log_call_error(content: str, pos: int) -> tuple[int, int, str]: + lineno, col = _line_col(content, pos) + return ( + lineno, + col, + "ESP_LOG call has no matching closing parenthesis, so it cannot be checked.", + ) + + LOG_BAD_CONTINUATION_RE = re.compile(r'\\n(?:[^ \\"\r\n\t]|"\s*\n\s*"[^ \\])') LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @@ -1146,16 +1196,16 @@ LOG_PERCENT_S_CONTINUATION_RE = re.compile(r'\\n(?:%s|"\s*\n\s*"%s)') @lint_content_check(include=cpp_include) def lint_log_multiline_continuation(fname, content): errs = [] - for log_match in LOG_MULTILINE_RE.finditer(content): - log_text = log_match.group(0) + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + errs.append(_unbalanced_log_call_error(content, log_start)) + continue for bad_match in LOG_BAD_CONTINUATION_RE.finditer(log_text): # %s may expand to a whitespace prefix at runtime, skip those if LOG_PERCENT_S_CONTINUATION_RE.match(log_text, bad_match.start()): continue # Calculate line number from position in full content - abs_pos = log_match.start() + bad_match.start() - lineno = content.count("\n", 0, abs_pos) + 1 - col = abs_pos - content.rfind("\n", 0, abs_pos) + lineno, col = _line_col(content, log_start + bad_match.start()) errs.append( ( lineno, @@ -1173,6 +1223,90 @@ def lint_log_multiline_continuation(fname, content): return errs +def _find_ternary_literals(text: str) -> Iterator[tuple[int, str]]: + """Yield (offset, literal) for every string literal used as a ternary branch.""" + branch = False + for m in LOG_TERNARY_LITERAL_RE.finditer(text): + tok = m.group(0) + # An empty literal is merged with every other string's terminator, so it costs no RAM, + # while a PSTR("") would add its own flash array; leave it alone. + if branch and tok[0] == '"' and tok != '""': + yield m.start(), tok + branch = tok[0] in "?:" + + +# LOG_STR_LITERAL is a no op everywhere except ESP8266, so code that never builds there is skipped +# to avoid churn: platform specific sources and components for ESP32, LibreTiny, RP2 and Zephyr only. +# A component belongs here only if it has no tests/components//test.esp8266-ard.yaml. +LOG_LITERAL_LINT_EXCLUDE = [ + "*_esp32.cpp", + "*_esp32_*.cpp", + "*_esp_idf.cpp", + "*_rmt.cpp", + "*_zephyr.cpp", + "*_bk72xx.cpp", + "*_libretiny.cpp", + "*_pico_w.cpp", + "*_host.cpp", + "esphome/components/esp32*/*", + "esphome/components/bk72xx*/*", + "esphome/components/ln882h*/*", + "esphome/components/ln882x*/*", + "esphome/components/rp2*/*", + "esphome/components/zephyr*/*", + "esphome/components/host/*", + "esphome/components/libretiny*/*", + "esphome/components/bluetooth_proxy/*", + "esphome/components/bluetooth_connection/*", + "esphome/components/ble_client/*", + "esphome/components/bedjet/*", + "esphome/components/anova/*", + "esphome/components/xiaomi_ble/*", + "esphome/components/bthome_mithermometer/*", + "esphome/components/usb_host/*", + "esphome/components/zigbee/*", + "esphome/components/lvgl/*", + # Test fixtures and host only unit tests - not production embedded code + "tests/integration/fixtures/*", + "tests/components/*", +] + + +@lint_content_check(include=cpp_include, exclude=LOG_LITERAL_LINT_EXCLUDE) +def lint_log_no_bare_literal_ternary( + fname: Path, content: str +) -> list[tuple[int, int, str]]: + errs = [] + for log_start, log_text in _iter_log_calls(content): + if log_text is None: + continue # reported by lint_log_multiline_continuation, which sees every file + # A NOLINT anywhere on the lines the call spans silences every branch in it + first_line = content.rfind("\n", 0, log_start) + 1 + last_line = content.find("\n", log_start + len(log_text)) + if NOLINT_RE.search( + content[first_line : last_line if last_line != -1 else None] + ): + continue + for offset, literal in _find_ternary_literals(log_text): + lineno, col = _line_col(content, log_start + offset) + errs.append( + ( + lineno, + col, + ( + "String literal used as a ternary branch in a log call. On ESP8266 the " + "log macro moves the format string to flash, but bare literal arguments " + "stay in RAM. Wrap each branch passed straight to the log call in " + f"{highlight('LOG_STR_LITERAL(...)')}:\n" + f" Before: {highlight(literal)}\n" + f" After: {highlight(f'LOG_STR_LITERAL({literal})')}\n" + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ), + ) + ) + return errs + + @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index d340a816c6d..5b4d4fe5c8b 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -4,12 +4,16 @@ The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() ca empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the NOLINT escape hatch at both placements a contributor would try. + +Also covers the ESP_LOG call scanner (_iter_log_calls) and the bare-literal-ternary lint. """ import importlib.util from pathlib import Path import sys +import pytest + SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve() sys.path.insert(0, str(SCRIPT_DIR)) _spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py") @@ -145,3 +149,125 @@ def test_nolint_at_end_of_log_line_suppresses() -> None: def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") + + +# --- ESP_LOG call scanner and bare-literal-ternary lint --- + + +def _calls(content: str) -> list[str | None]: + return [text for _, text in ci_custom._iter_log_calls(content)] + + +def _ternary_errors(content: str) -> list[tuple[int, int]]: + errs = ci_custom.lint_log_no_bare_literal_ternary(Path("x.cpp"), content) + return [(line, col) for line, col, _ in errs] + + +@pytest.mark.parametrize( + "content", + [ + 'ESP_LOGD(TAG, "a ) b ( c; d")', + 'ESP_LOGD(TAG, "quote \\" inside")', + "ESP_LOGD(TAG, \"%s\", format_hex_pretty(x, '-', false).c_str())", + "ESP_LOGD(TAG, \"%c%c\", '(', ')')", + "ESP_LOGD(TAG, \"%d\", 1'000'000)", + 'ESP_LOGD(TAG, // it\'s a comment with ) and (\n "x")', + 'ESP_LOGD(TAG, /* :) */ "x")', + 'ESP_LOGD(TAG, "%s", R"(say "hi" :) )")', + 'ESP_LOGD(TAG, "%s", R"x(a)"b)x")', + ], +) +def test_iter_log_calls_spans_whole_call(content: str) -> None: + calls = _calls(content + ";\nint other = (1);") + assert calls == [content] + + +def test_iter_log_calls_reports_unbalanced_call_once() -> None: + content = 'ESP_LOGD(TAG, "x";\nvoid f();' + assert _calls(content) == [None] + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert len(errs) == 1 + assert errs[0][:2] == (1, 1) + assert "no matching closing parenthesis" in errs[0][2] + assert _ternary_errors(content) == [] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + # A ; inside the format string no longer cuts the call short + ('ESP_LOGD(TAG, "a; b\\nc %s", x);', [(1, 20)]), + # A \n%s continuation is exempt since %s may expand to leading whitespace + ('ESP_LOGD(TAG, "a\\n%s", x);', []), + ('ESP_LOGD(TAG, "a\\n b");', []), + ], +) +def test_multiline_continuation_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + errs = ci_custom.lint_log_multiline_continuation(Path("x.cpp"), content) + assert [(line, col) for line, col, _ in errs] == expected + + +def test_exclusion_list_only_names_components_without_esp8266_tests() -> None: + root = Path(__file__).parent / ".." / ".." + for pattern in ci_custom.LOG_LITERAL_LINT_EXCLUDE: + if not pattern.startswith("esphome/components/"): + continue + prefix = pattern.removeprefix("esphome/components/").split("/")[0] + comps = list((root / "esphome" / "components").glob(prefix)) + assert comps, f"{pattern!r} matches no component" + for comp in comps: + test = root / "tests" / "components" / comp.name / "test.esp8266-ard.yaml" + assert not test.exists(), ( + f"{comp.name} builds for ESP8266, drop {pattern!r}" + ) + + +def test_unbalanced_calls_are_reported_by_a_check_that_sees_every_file() -> None: + # lint_log_no_bare_literal_ternary skips unbalanced calls and relies on this + checks = {c["func"].__name__: c for c in ci_custom.LINT_CONTENT_CHECKS} + continuation = checks["lint_log_multiline_continuation"] + ternary = checks["lint_log_no_bare_literal_ternary"] + assert continuation["exclude"] == [] + assert continuation["include"] == ternary["include"] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ('ESP_LOGD(TAG, "%s", x ? "on" : "off");', [(1, 25), (1, 32)]), + ( + 'ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : LOG_STR_LITERAL("off"));', + [], + ), + ('ESP_LOGD(TAG, "%s", x ? LOG_STR_LITERAL("on") : "off");', [(1, 49)]), + ('ESP_LOGD(TAG, "%s", x ? "on" : "");', [(1, 25)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no");', + [(2, 14), (3, 14)], + ), + ("ESP_LOGD(TAG, \"%c\", x ? '1' : '0');", []), + ('ESP_LOGD(TAG, "a ? b : c %s", x ? "on" : "off");', [(1, 35), (1, 42)]), + ('ESP_LOGD(TAG, "x:" "y %s", p);', []), + ('ESP_LOGD(TAG, "%s", x ? "on" : "off"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s",\n x ? "yes"\n : "no"); // NOLINT', []), + ('ESP_LOGD(TAG, "%s", x ? /* c */ "on" : "off");', [(1, 33), (1, 40)]), + ( + 'ESP_LOGD(TAG, "%s",\n x ? "on" // NOLINT(some-clang-check)\n : "off");', + [(2, 14), (3, 14)], + ), + ], +) +def test_ternary_literal_detection( + content: str, expected: list[tuple[int, int]] +) -> None: + assert _ternary_errors(content) == expected + + +def test_ternary_error_message_names_the_literal() -> None: + errs = ci_custom.lint_log_no_bare_literal_ternary( + Path("x.cpp"), 'ESP_LOGD(TAG, "%s", x ? "enabled" : LOG_STR_LITERAL("off"));' + ) + assert len(errs) == 1 + assert 'LOG_STR_LITERAL("enabled")' in errs[0][2] From 1dbbf3f45a93834aa5d85da5de131898778d5c92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:40:22 -0500 Subject: [PATCH 309/759] [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) --- esphome/espidf/runner.py | 99 +++++++++++++++++++---- tests/unit_tests/test_espidf_runner.py | 104 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7ed11d75543..bf563a4f438 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -74,6 +74,64 @@ FILTER_IDF_LINES: list[str] = [ r"Stopping at filesystem boundary", ] +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + + +def _get_kernel32(): + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class _Utf8Console: + """Keep an attached Windows console on UTF-8 for the length of the build. + + The build tree runs in UTF-8 mode, so esp_idf_size draws its table with + Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``, + and CMake re-decodes the child's output with the console code page before + printing it, which turns the table into mojibake on any code page but + UTF-8. Every process in the build shares this console, so switching it + here covers CMake too. The old code pages go back on exit so the user's + terminal is left as it was. + + A console that is already on UTF-8 is left alone. The code page belongs to + the console, not to this process, so a build that overlaps another one + must not save UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record the old pages first so a switch that fails part way through + # still gets put back on exit. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + def main() -> int: # ---- sys.path fix-up --------------------------------------------------- @@ -269,8 +327,29 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + class _FilteredStreams: + """Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims. + + On exit each shim releases a last line that never got its + terminator. The shims made here are drained rather than whatever + ``sys.stdout`` holds by then, which the script is free to replace. + """ + + def __init__(self, filter_lines: list[str] | None) -> None: + self._stdout = _FilteringTTYStream(sys.stdout, filter_lines) + self._stderr = _FilteringTTYStream(sys.stderr, filter_lines) + + def __enter__(self) -> None: + sys.stdout = self._stdout # type: ignore[assignment] + sys.stderr = self._stderr # type: ignore[assignment] + + def __exit__(self, *exc_info: object) -> None: + # Drain stderr from a finally so a surprise from the first one + # cannot strand the second. + try: + self._stdout.drain() + finally: + self._stderr.drain() # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -288,19 +367,11 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the streams get a chance to - # release a last line that never got its terminator. Drain the shims we - # made rather than sys.stdout, which the script is free to replace, and - # report instead of raising so cleanup cannot bury the real exit code. - try: + # fall through and exit with 0. Either way the context managers drain + # the streams and put the console back on the way out, and they report + # instead of raising so cleanup cannot bury the real exit code. + with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()): runpy.run_path(script_path, run_name="__main__") - finally: - # Drain stderr from a finally so a surprise from the first one cannot - # strand the second. - try: - stdout_shim.drain() - finally: - stderr_shim.drain() return 0 diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index e4cc6e137e7..71ccfee1f2c 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes( # Join before leaving the block, so the reader is done rather than # racing ``Popen`` closing the pipe under it. reader.join(1.0) + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_main_runs_the_build_with_a_utf8_console( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An attached console is switched to UTF-8 and then put back.""" + kernel32 = _FakeKernel32(850, 850) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [ + ("SetConsoleCP", runner.UTF8_CODEPAGE), + ("SetConsoleOutputCP", runner.UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_main_restores_the_console_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failing build must not leave the user's console on UTF-8.""" + kernel32 = _FakeKernel32(437, 437) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + _prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py") + + with pytest.raises(SystemExit): + runner.main() + + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_main_restores_the_console_when_the_switch_fails_part_way( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failed output page switch must not strand the changed input page.""" + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +def test_main_leaves_the_console_alone_when_there_is_none( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Without a console the code page calls return 0 and nothing is set.""" + kernel32 = _FakeKernel32(0, 0) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +def test_main_leaves_a_console_already_on_utf8_alone( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An overlapping build must not save UTF-8 as the page to restore.""" + kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert runner._get_kernel32() is None From f433f99e907719715158e6c249f3a162ac5949de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:57:59 -0500 Subject: [PATCH 310/759] [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp32_hosted/__init__.py | 2 ++ .../esp32_hosted/test-no-wifi.esp32-p4-idf.yaml | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 748be9ae4cf..ae1f821c178 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -262,7 +262,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back - "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 21626e432b6..6943efc3cb8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None: # symbols are simply unused and never register a callback at runtime. if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: add_define("USE_ESP_NOW_HOSTED") + # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml new file mode 100644 index 00000000000..2f57abd296e --- /dev/null +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim. +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From a63dc0d9c920fb8b0c1c5084afba9e2322c432d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 10:20:41 -0500 Subject: [PATCH 311/759] [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) --- esphome/components/api/api_server.cpp | 26 +++++- esphome/components/api/api_server.h | 5 ++ esphome/components/esphome/ota/__init__.py | 18 ++--- .../components/esphome/ota/ota_esphome.cpp | 22 +++-- esphome/components/esphome/ota/ota_esphome.h | 9 ++- esphome/components/noise/__init__.py | 18 +++-- esphome/core/defines.h | 1 - tests/component_tests/ota/test_esphome_ota.py | 26 +++--- .../host_ota_encrypted_safe_mode.yaml | 13 +++ ...ost_ota_provisioned_api_key_safe_mode.yaml | 11 +++ tests/integration/host_prefs.py | 35 ++++++-- tests/integration/test_host_ota.py | 80 +++++++++++++++++-- tests/integration/test_safe_mode_loop_runs.py | 12 +-- 13 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml create mode 100644 tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e9..d74de98c75e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -29,6 +29,29 @@ static const char *const TAG = "api"; // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_API_NOISE +static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL; +#endif + +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +bool load_saved_noise_psk(noise::psk_t &out) { + SavedNoisePsk saved; +#ifdef USE_PREFERENCE_KEY_LOOKUP + const bool loaded = + global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast(&saved), sizeof(saved)); +#else + // Slot backends need the reservation walk; it only lands on the record when the reservations before + // it match a normal boot, otherwise the type checked checksum fails the load + const bool loaded = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true).load(&saved); +#endif + // The all-zeros record means no key + if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk)) + return false; + out = saved.psk; + return true; +} +#endif + APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { @@ -43,8 +66,7 @@ void APIServer::setup() { #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build - uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); + this->noise_pref_ = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true); #ifndef USE_API_NOISE_PSK_FROM_YAML // A cleared record loads fine but holds no key if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e5a22dcef82..b8a71c93e7a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -43,6 +43,11 @@ struct SavedNoisePsk { noise::psk_t psk; } PACKED; // NOLINT #endif +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when +/// there is no key +bool load_saved_noise_psk(noise::psk_t &out); +#endif class APIServer final : public Component, public Controller diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bcf2a2271cb..bace13c17fc 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -316,20 +316,16 @@ async def to_code(config: ConfigType) -> None: # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement api_conf = CORE.config.get(CONF_API) or {} - encryption_conf = config.get(CONF_ENCRYPTION) - own_key = None - if encryption_conf is not None and static_encryption_key(api_conf) is None: - own_key = encryption_conf[CONF_KEY] - if own_key is not None: + if key := static_encryption_key(config) or static_encryption_key(api_conf): + # Build time key: the ota keeps its own pointer so safe mode, which + # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) elif CONF_ENCRYPTION in api_conf: + # Runtime key: found in the api server, or in preferences in safe mode cg.add_define("USE_OTA_ENCRYPTION") - cg.add_define("USE_OTA_ENCRYPTION_FROM_API") - if static_encryption_key(api_conf) is None: - # The key arrives at runtime, so the offer has to look for it - cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") - if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if CONF_ENCRYPTION in config: cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3010df10561..d353d01d205 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,5 +1,5 @@ #include "ota_esphome.h" -#ifdef USE_OTA_ENCRYPTION_FROM_API +#ifdef USE_OTA_ENCRYPTION_PROVISIONED #include "esphome/components/api/api_server.h" #endif #ifdef USE_OTA @@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota"; #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { -#ifdef USE_OTA_ENCRYPTION_FROM_API - return api::global_api_server->get_noise_ctx(); -#else - return this->noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The api server holds the live key; safe mode never constructs it, and then + // noise_ctx_ holds the saved key setup() found, if any + if (api::global_api_server != nullptr) + return api::global_api_server->get_noise_ctx(); #endif + return this->noise_ctx_; } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; @@ -58,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() { } void ESPHomeOTAComponent::setup() { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Safe mode never constructs the api server, so read the key it saved + noise::psk_t psk; + if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) { + this->saved_psk_ = RAMAllocator().make_unique(psk); + if (this->saved_psk_ != nullptr) { + this->noise_ctx_.set_psk(this->saved_psk_->data()); + } + } +#endif this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { this->server_failed_(LOG_STR("creation")); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 68dd0ffb9ef..92ba094c8de 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) +#ifdef USE_OTA_ENCRYPTION /// psk points at 32 bytes that live in flash for the life of the program void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif @@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; }; - // The api server's live context when the api has encryption, else our own + // The api server's live context when it exists, otherwise our own (a build + // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); @@ -148,8 +149,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION -#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Backs noise_ctx_ in safe mode, where no api server holds the saved key + RAMUniquePtr saved_psk_; #endif RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 6067fde1642..47cd4cfc677 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,11 +5,12 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" noise_ns = cg.esphome_ns.namespace("noise") @@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None: def new_psk_progmem(parent_id: ID, key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer - so the key never occupies RAM.""" - return cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decode_encryption_key(key)), - ) + so the key never occupies RAM. Components sharing one key (api and ota) + share the array.""" + decoded = decode_encryption_key(key) + arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) + if (array := arrays.get(decoded)) is None: + array = arrays[decoded] = cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decoded), + ) + return array def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fb76f90bcf3..30c9dc27ede 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -283,7 +283,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 235ad902dbc..d8bcd7e2758 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -476,43 +476,36 @@ def test_static_encryption_key() -> None: ("yaml_name", "defines_present", "defines_absent"), [ # An api key alone compiles the transport in without requiring it; - # the device uses the api server's key, not a copy + # the ota keeps its own pointer to the key so safe mode, which never + # constructs the api server, can still use it ( "api_key_offer", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A password still guards plaintext uploads on an offering device ( "api_key_offer_password", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"}, + {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # Without api encryption the ota key is the device's own ( "own_key", {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, - {"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"}, + {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A key provisioned at runtime lives in the api server; the device # offers with it once provisioned and never requires it ( "runtime_api_key", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_FROM_API", - "USE_OTA_ENCRYPTION_PROVISIONED", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"}, {"USE_OTA_ENCRYPTION_REQUIRED"}, ), # No api encryption at all keeps the noise glue out of the build @@ -522,7 +515,6 @@ def test_static_encryption_key() -> None: { "USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED", }, ), @@ -541,8 +533,10 @@ def test_encryption_offer_codegen( assert defines_present <= defines assert not (defines_absent & defines) encrypted = "USE_OTA_ENCRYPTION" in defines_present - own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present + own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + # The api shares the ota's array instead of emitting the same key twice + assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml new file mode 100644 index 00000000000..57f3d57a010 --- /dev/null +++ b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml @@ -0,0 +1,13 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + encryption: +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml new file mode 100644 index 00000000000..c00954515b1 --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml @@ -0,0 +1,11 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index 5f526dce5f4..b160f307628 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -15,6 +15,12 @@ import os from pathlib import Path import struct +_ENTRY = struct.Struct(" Path: """Return the on-disk prefs file path for a host-platform device. @@ -42,16 +48,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: for key, data in entries.items(): if len(data) > 255: raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: - """Write a single preference entry, replacing the file's contents. +def read_host_prefs(device_name: str) -> dict[int, bytes]: + """Read the preference entries of a host-platform device; empty when + the file does not exist.""" + path = host_prefs_path(device_name) + if not path.exists(): + return {} + payload = path.read_bytes() + entries: dict[int, bytes] = {} + pos = 0 + while pos < len(payload): + key, length = _ENTRY.unpack_from(payload, pos) + pos += _ENTRY.size + entries[key] = payload[pos : pos + length] + pos += length + return entries - Returns the path that was written. - """ - return write_host_prefs(device_name, {key: data}) + +def force_safe_mode(device_name: str) -> None: + """Make the next boot of a host-platform device enter safe mode; other + saved preferences are kept.""" + entries = read_host_prefs(device_name) + entries[_SAFE_MODE_RTC_KEY] = struct.pack(" None: + """Provision PROVISIONING_PSK over the api and wait for it to activate.""" + async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -227,6 +237,31 @@ async def test_host_ota_encrypted( await dev.ota(None, API_KEY, "encrypted OTA reported failure") +@pytest.mark.asyncio +async def test_host_ota_encrypted_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Safe mode never constructs the api server, so an encrypted OTA with the + api key has to run on the ota component's own copy of that key.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # The api port never opens in safe mode, so wait for the log line instead + force_safe_mode(DEVICE_NAME) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + # The safe mode boot clears the counter, so the re-exec boots normally + await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure") + + @pytest.mark.asyncio async def test_host_ota_api_key_offer_with_password( yaml_config: str, @@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key( None, None, "plaintext upload to an unprovisioned device must succeed" ) - async with api_client_connected( - port=dev.api_port, noise_psk=ZERO_PSK - ) as client: - assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True - await asyncio.sleep(KEY_ACTIVATION_DELAY) + await _provision_key(dev, api_client_connected) key = PROVISIONING_PSK.decode() await dev.ota( @@ -319,6 +350,45 @@ async def test_host_ota_provisioned_api_key( await dev.ota(None, None, "plaintext must stay accepted on an offering device") +@pytest.mark.asyncio +async def test_host_ota_provisioned_api_key_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """Safe mode never constructs the api server, so the OTA has to load the + provisioned key from preferences itself to keep encrypting there.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _provision_key(dev, api_client_connected) + + # The saved key is already on disk; a host reboot outside an OTA just + # exits, so safe mode takes a second start + force_safe_mode(DEVICE_NAME) + key = PROVISIONING_PSK.decode() + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + await dev.ota( + None, + key, + "encrypted upload with the provisioned key must succeed in safe mode", + ) + # The re-exec boots normally and the api reads the same record + async with api_client_connected(port=dev.api_port, noise_psk=key): + pass + + @pytest.mark.asyncio async def test_host_ota_rejects_garbage( yaml_config: str, diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 652877fc092..55756fca13d 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -25,19 +25,13 @@ from __future__ import annotations import asyncio import re -import struct import pytest from .conftest import run_binary -from .host_prefs import clear_host_prefs, write_host_pref +from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter -# Must match esphome::safe_mode::RTC_KEY in safe_mode.h -SAFE_MODE_RTC_KEY = 233825507 -# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC -ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE - DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs( # Compile finished successfully; pre-populate prefs so the *next* run # enters safe mode immediately. - write_host_pref( - DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" Date: Thu, 17 Sep 2026 17:38:56 +0200 Subject: [PATCH 312/759] [web_server_idf] Close stalled EventSource sessions via HTTPD (#17800) Co-authored-by: Jeroen Jansen Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/web_server/web_server.cpp | 8 +- .../web_server_idf/web_server_idf.cpp | 112 +++++++++++++++--- .../web_server_idf/web_server_idf.h | 20 +++- 3 files changed, 115 insertions(+), 25 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8b0dfe166fb..bad3d050f3a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -200,10 +200,10 @@ void DeferredUpdateEventSource::process_deferred_queue_() { deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send } else { - // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_() - // The implementations differ due to platform-specific APIs (DISCARDED vs HTTPD_SOCK_ERR_TIMEOUT, close() vs - // fd_.store(0)), but the failure counting and timeout logic should be kept in sync. If you change this logic, - // also update the ESP-IDF implementation. + // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_(). + // The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and + // closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without + // send progress. Keep that stall policy in sync when changing either side. this->consecutive_send_failures_++; if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { // Too many failures, connection is likely dead diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9550570cdcf..0062556063f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -6,6 +6,7 @@ #include #include +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -728,7 +729,7 @@ bool AsyncEventSource::loop() { for (size_t i = 0; i < this->sessions_.size();) { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) - if (ses->fd_.load() == 0) { + if (ses->safe_to_delete_()) { // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) @@ -751,7 +752,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { } for (auto *rsp : incoming) { // Already disconnected? Drop it; skip on_connect_/session start on a dead session. - if (rsp->fd_.load() == 0) { + if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; } @@ -865,10 +866,16 @@ void AsyncEventSourceResponse::deq_push_back_with_dedup_(void *source, message_g } void AsyncEventSourceResponse::process_deferred_queue_() { + if (this->close_requested_) { + return; + } while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { + if (this->close_requested_ || deferred_queue_.empty()) { + return; + } // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -877,8 +884,64 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } } +void AsyncEventSourceResponse::request_close_() { + if (!this->close_requested_) { + this->close_requested_ = true; + this->deferred_queue_.clear(); + this->event_buffer_.clear(); + this->event_bytes_sent_ = 0; + this->next_close_attempt_ms_ = App.get_loop_component_start_time(); + } + + this->process_close_(); +} + +void AsyncEventSourceResponse::process_close_() { + if (!this->close_requested_ || this->close_work_queued_.load(std::memory_order_acquire)) { + return; + } + const int fd = this->fd_.load(); + if (fd == 0) { + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + if (static_cast(now - this->next_close_attempt_ms_) < 0) { + return; + } + + // Queue an identity-checked shutdown on the HTTPD task. The public + // httpd_sess_trigger_close() queues only a reusable fd/session slot and can + // therefore close a new client if the original peer disconnects meanwhile. + this->close_work_queued_.store(true, std::memory_order_release); + const esp_err_t err = httpd_queue_work(this->hd_, &AsyncEventSourceResponse::close_session_work, this); + this->next_close_attempt_ms_ = now + (err == ESP_OK ? CLOSE_CONFIRM_INTERVAL_MS : CLOSE_RETRY_INTERVAL_MS); + if (err == ESP_OK) { + return; + } + + this->close_work_queued_.store(false, std::memory_order_release); + if (!this->close_retry_warning_logged_) { + ESP_LOGW(TAG, "Failed to queue EventSource close (%s); retrying", esp_err_to_name(err)); + this->close_retry_warning_logged_ = true; + } +} + +void AsyncEventSourceResponse::close_session_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + // The HTTPD task remains the session owner. Shutting the socket down makes + // its next select/recv path delete the session and invoke destroy(). + shutdown(fd, SHUT_RDWR); + } + + // Release self only after the HTTPD-task callback has finished every access. + response->close_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::process_buffer_() { - if (event_buffer_.empty()) { + if (this->close_requested_ || event_buffer_.empty()) { return; } if (event_bytes_sent_ == event_buffer_.size()) { @@ -892,32 +955,33 @@ void AsyncEventSourceResponse::process_buffer_() { httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, remaining, 0); if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT) { // EAGAIN/EWOULDBLOCK - socket buffer full, try again later - // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_() - // The implementations differ due to platform-specific APIs (HTTPD_SOCK_ERR_TIMEOUT vs DISCARDED, fd_.store(0) vs - // close()), but the failure counting and timeout logic should be kept in sync. If you change this logic, also - // update the Arduino implementation. - this->consecutive_send_failures_++; - if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { - // Too many failures, connection is likely dead - ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu16 " failed sends", - this->consecutive_send_failures_); - this->fd_.store(0); // Mark for cleanup - this->deferred_queue_.clear(); + // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_(). + // The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership. + const uint32_t now = App.get_loop_component_start_time(); + if (this->send_failure_started_ms_ == 0) { + this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall. + } + if (static_cast(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) { + ESP_LOGW(TAG, "Closing stuck EventSource connection after %" PRIu32 " ms without send progress", + now - this->send_failure_started_ms_); + this->request_close_(); } return; } if (bytes_sent == HTTPD_SOCK_ERR_FAIL) { - // Real socket error - connection will be closed by httpd and destroy callback will be called + // Low-level asynchronous sends do not make HTTPD close the session automatically. + this->request_close_(); return; } if (bytes_sent <= 0) { // Unexpected error or zero bytes sent ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent); + this->request_close_(); return; } - // Successful send - reset failure counter - this->consecutive_send_failures_ = 0; + // Successful send - reset stall tracking + this->send_failure_started_ms_ = 0; event_bytes_sent_ += bytes_sent; // Log partial sends for debugging @@ -933,20 +997,26 @@ void AsyncEventSourceResponse::process_buffer_() { } void AsyncEventSourceResponse::loop() { + if (this->close_requested_) { + this->process_close_(); + return; + } process_buffer_(); process_deferred_queue_(); + if (this->close_requested_) + return; // One step per loop; refusals retry next pass this->entities_iterator_.try_advance(1); } bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { - if (this->fd_.load() == 0) { + if (this->fd_.load() == 0 || this->close_requested_) { return false; } process_buffer_(); - if (!event_buffer_.empty()) { + if (this->close_requested_ || !event_buffer_.empty()) { // there is still pending event data to send first return false; } @@ -1098,6 +1168,10 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e process_buffer_(); process_deferred_queue_(); + if (this->close_requested_) { + return; + } + if (!event_buffer_.empty() || !deferred_queue_.empty()) { // outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point // trying to send first diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 743d296d73a..7894206102f 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -301,6 +301,14 @@ class AsyncEventSourceResponse { void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); + void request_close_(); + void process_close_(); + static void close_session_work(void *arg); + + // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + bool safe_to_delete_() const { + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + } static void destroy(void *p); AsyncEventSource *server_; @@ -311,8 +319,16 @@ class AsyncEventSourceResponse { esphome::web_server::ListEntitiesIterator entities_iterator_; std::string event_buffer_; size_t event_bytes_sent_; - uint16_t consecutive_send_failures_{0}; - static constexpr uint16_t MAX_CONSECUTIVE_SEND_FAILURES = 2500; // ~20 seconds at 125Hz loop rate + uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. + uint32_t next_close_attempt_ms_{0}; + // Main-loop only; the HTTPD task never reads or writes this flag. + bool close_requested_{false}; + bool close_retry_warning_logged_{false}; + // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. + std::atomic close_work_queued_{false}; + static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; + static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; + static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From f6897f2257a3533abf4445346b958f9128810c76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:57:00 -0500 Subject: [PATCH 313/759] Bump codecov/codecov-action from 7.0.0 to 7.1.0 (#19382) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689baa1292e..bb461678cb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,7 +321,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -350,7 +350,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 with: run_command: empty-upload force: true From 28189ea0260e7c5e5c19717c908e4cdf11bd62bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:32:33 -0500 Subject: [PATCH 314/759] Bump docker/setup-buildx-action from 4.3.0 to 4.4.0 in the docker-actions group (#19404) Signed-off-by: dependabot[bot] --- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 829bdd5f981..c55b5326ece 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,7 +67,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Determine tag and whether to push id: tag @@ -159,7 +159,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to the GitHub container registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0dee8165c5..efccad9ad96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From aba49c3d4f70fa2e045afb084bdbe6ff51c9efb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:32:42 -0500 Subject: [PATCH 315/759] Bump docker/build-push-action from 7.3.0 to 7.4.0 in /.github/actions/build-image (#19403) Signed-off-by: dependabot[bot] --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 133d7ca8d82..f6565a3916a 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 623aec2984c67c9b2c41696e3181ba0ded74281e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 18 Sep 2026 17:18:44 -0500 Subject: [PATCH 316/759] [mdns] Advertise ota_signed when signed OTA verification is compiled in (#19407) Co-authored-by: Claude Opus 5 (1M context) --- esphome/components/mdns/mdns_component.cpp | 10 ++++++++++ esphome/zeroconf.py | 5 +++++ .../mdns/test-signed-ota.esp32-s3-idf.yaml | 15 +++++++++++++++ tests/unit_tests/test_zeroconf.py | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/components/mdns/test-signed-ota.esp32-s3-idf.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9d1e5853391..4f236153c2c 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -118,6 +118,9 @@ void MDNSComponent::compile_records_(StaticVector MagicMock: """Build a fake ``AsyncServiceInfo`` with the TXT records we care about. @@ -48,6 +49,8 @@ def _make_service_info( properties[b"friendly_name"] = friendly_name.encode() if version is not None: properties[b"version"] = version.encode() + if ota_signed is not None: + properties[b"ota_signed"] = ota_signed.encode() info.properties = properties info.load_from_cache.return_value = True return info @@ -77,6 +80,7 @@ def test_added_service_populates_import_state_and_fires_callback() -> None: assert entry.project_version == "1.0.0" assert entry.network == "wifi" assert entry.friendly_name == "Living Room" + assert entry.ota_signed is False on_update.assert_called_once_with(name, entry) @@ -206,6 +210,20 @@ def test_network_defaults_to_wifi_when_txt_absent() -> None: assert discovery.import_state[name].network == "wifi" +def test_ota_signed_txt_is_parsed() -> None: + """``ota_signed=1`` marks a device that only accepts signed OTA images. + + Lets a consumer check whether it holds a key the device trusts before + an update is attempted, instead of failing at install time. + """ + discovery = DashboardImportDiscovery() + info = _make_service_info(ota_signed="1") + name = f"signed.{ESPHOME_SERVICE_TYPE}" + discovery._process_service_info(name, info) + + assert discovery.import_state[name].ota_signed is True + + def test_friendly_name_optional() -> None: """``friendly_name`` may be ``None`` if the device doesn't broadcast it. From a53efcf6468a11d96c5c762b3741fae8919333e3 Mon Sep 17 00:00:00 2001 From: Xiretza Date: Fri, 18 Sep 2026 22:30:35 +0000 Subject: [PATCH 317/759] [core] fix capitalisation of "pin" (#19405) --- esphome/pins.py | 2 +- tests/component_tests/esp32/test_esp32.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/pins.py b/esphome/pins.py index d6393508abf..34a406c2733 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -272,7 +272,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): num = conf[CONF_NUMBER] if num in strapping_pin_list and not conf.get(CONF_IGNORE_STRAPPING_WARNING): logger.warning( - "GPIO%s is a strapping PIN and should only be used for I/O with care.\n" + "GPIO%s is a strapping pin and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", num, diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a42d244ac8f..3ba819dee7b 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1457,7 +1457,7 @@ def test_esp32_s31_gpio_validation( pin = {CONF_NUMBER: 36, CONF_MODE: input_mode} with caplog.at_level("WARNING"): validate_supports(pin) - assert "GPIO36 is a strapping PIN" in caplog.text + assert "GPIO36 is a strapping pin" in caplog.text _TLS_SERVER_OPTIONS = ( From f94661561df60e2c68593f757d340ee0b9c770fd Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 18 Sep 2026 19:10:11 -0400 Subject: [PATCH 318/759] [socket] Fix set_sockaddr() accepting malformed IPv4 addresses (#19394) Co-authored-by: Claude Sonnet 5 --- esphome/components/socket/socket.cpp | 6 +- tests/components/socket/__init__.py | 16 ++ tests/components/socket/conftest.py | 12 -- .../components/socket/test_sockaddr_host.cpp | 146 ++++++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 tests/components/socket/__init__.py delete mode 100644 tests/components/socket/conftest.py create mode 100644 tests/components/socket/test_sockaddr_host.cpp diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 212da80312b..567937a4db0 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -185,7 +185,11 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return 0; } #else - server->sin_addr.s_addr = inet_addr(ip_address); + // inet_aton() reports failure separately from the address, so 255.255.255.255 needs no special case + if (inet_aton(ip_address, &server->sin_addr) == 0) { + errno = EINVAL; + return 0; + } #endif server->sin_port = htons(port); return sizeof(sockaddr_in); diff --git a/tests/components/socket/__init__.py b/tests/components/socket/__init__.py new file mode 100644 index 00000000000..8a3da97e23d --- /dev/null +++ b/tests/components/socket/__init__.py @@ -0,0 +1,16 @@ +# This file's presence makes pytest treat this directory as a package named "socket", +# shadowing the stdlib module -- no test_*.py (Python-level) tests can live here. +# required for cpp unit testing. +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NETWORK_IPV6", True) + + manifest.to_code = to_code_testing diff --git a/tests/components/socket/conftest.py b/tests/components/socket/conftest.py deleted file mode 100644 index 5d93cac232a..00000000000 --- a/tests/components/socket/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Configuration file for socket component tests.""" - -import pytest - -from esphome.core import CORE - - -@pytest.fixture(autouse=True) -def reset_core(): - """Reset CORE after each test.""" - yield - CORE.reset() diff --git a/tests/components/socket/test_sockaddr_host.cpp b/tests/components/socket/test_sockaddr_host.cpp new file mode 100644 index 00000000000..74ba5de8ce7 --- /dev/null +++ b/tests/components/socket/test_sockaddr_host.cpp @@ -0,0 +1,146 @@ +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// Independent of USE_NETWORK_IPV6 +// ========================================================================= + +TEST(SetSockaddr, IPv4Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "192.168.1.1", 6053); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_family, AF_INET); + EXPECT_EQ(ntohs(in->sin_port), 6053); + EXPECT_EQ(in->sin_addr.s_addr, htonl(0xC0A80101)); +} + +TEST(SetSockaddr, IPv4Broadcast) { + // 255.255.255.255 is INADDR_NONE's bit pattern but also a valid address. + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "255.255.255.255", 12345); + ASSERT_EQ(len, sizeof(sockaddr_in)); + auto *in = reinterpret_cast(&addr); + EXPECT_EQ(in->sin_addr.s_addr, ESPHOME_INADDR_NONE); +} + +TEST(SetSockaddr, IPv4MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "not-an-ip", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, IPv4BufferTooSmallYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(sockaddr_in) - 1, "192.168.1.1", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddr, StringOverloadMatchesCStringOverload) { + struct sockaddr_storage addr_c {}; + struct sockaddr_storage addr_s {}; + socklen_t len_c = set_sockaddr((struct sockaddr *) &addr_c, sizeof(addr_c), "10.0.0.5", 80); + socklen_t len_s = set_sockaddr((struct sockaddr *) &addr_s, sizeof(addr_s), std::string("10.0.0.5"), 80); + ASSERT_EQ(len_c, len_s); + EXPECT_EQ(std::memcmp(&addr_c, &addr_s, len_c), 0); +} + +TEST(FormatSockaddrTo, IPv4RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "203.0.113.7", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "203.0.113.7"); +} + +TEST(FormatSockaddrTo, UnknownFamilyYieldsEmptyString) { + struct sockaddr addr {}; + addr.sa_family = AF_UNSPEC; + char buf[SOCKADDR_STR_LEN] = {'x', '\0'}; + size_t written = format_sockaddr_to(&addr, sizeof(addr), buf); + EXPECT_EQ(written, 0u); + EXPECT_STREQ(buf, ""); +} + +// ========================================================================= +// IPv6 - this directory's __init__.py forces USE_NETWORK_IPV6 on +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(SetSockaddr, IPv6Basic) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::1", 443); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 443); + EXPECT_TRUE(IN6_IS_ADDR_LOOPBACK(&in6->sin6_addr)); +} + +TEST(SetSockaddr, IPv6MalformedYieldsZeroAndEINVAL) { + struct sockaddr_storage addr {}; + errno = 0; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "gg::not-ipv6", 1); + EXPECT_EQ(len, 0u); + EXPECT_EQ(errno, EINVAL); +} + +TEST(SetSockaddrAny, FillsIPv6AnyAddressWithPort) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr_any((struct sockaddr *) &addr, sizeof(addr), 9999); + ASSERT_EQ(len, sizeof(sockaddr_in6)); + auto *in6 = reinterpret_cast(&addr); + EXPECT_EQ(in6->sin6_family, AF_INET6); + EXPECT_EQ(ntohs(in6->sin6_port), 9999); + EXPECT_TRUE(IN6_IS_ADDR_UNSPECIFIED(&in6->sin6_addr)); +} + +TEST(FormatSockaddrTo, IPv6RoundTrip) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "2001:db8::1", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "2001:db8::1"); +} + +TEST(FormatSockaddrTo, V4MappedFormatsAsPlainIPv4) { + struct sockaddr_storage addr {}; + socklen_t len = set_sockaddr((struct sockaddr *) &addr, sizeof(addr), "::ffff:198.51.100.9", 1); + ASSERT_GT(len, 0u); + char buf[SOCKADDR_STR_LEN]; + size_t written = format_sockaddr_to((struct sockaddr *) &addr, len, buf); + EXPECT_GT(written, 0u); + EXPECT_STREQ(buf, "198.51.100.9"); +} + +TEST(SocketIp, CreatesIPv6Socket) { + auto sock = socket_ip(SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + struct sockaddr_storage addr {}; + socklen_t len = sizeof(addr); + ASSERT_EQ(sock->getsockname((struct sockaddr *) &addr, &len), 0); + EXPECT_EQ(addr.ss_family, AF_INET6); +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST From c10c06eeb5ca7dbc49e7edf07a841bebbdc99b94 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:44:33 +1200 Subject: [PATCH 319/759] [ci] Add code-review agent skill for GitHub Copilot (#19370) --- .agents/skills/code-review/SKILL.md | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .agents/skills/code-review/SKILL.md diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 00000000000..f0c2891ba4a --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,116 @@ +--- +name: code-review +description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy. +--- + +# Reviewing ESPHome pull requests + +ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers +(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and +flash are scarce, and code runs unattended for months. + +`AGENTS.md` in the repository root is the full contributor guide and the +authority when it disagrees with this summary. The developer documentation at +https://developers.esphome.io explains the component lifecycle and the reasoning +behind these rules. This skill lists the concrete things worth flagging in a +review; read `AGENTS.md` for the detail behind any item. + +Only raise findings that the diff actually introduces or changes. Do not ask for +drive-by cleanup of pre-existing code the PR did not touch. + +## Memory and embedded constraints (highest value) + +Heap allocation after `setup()` is treated as a reliability bug, not a +performance nit, because it fragments a small shared heap. Flag: + +- New heap allocation on a hot path or after setup that could be avoided. +- `std::vector` where the size is known at compile time (use `std::array`, or + `StaticVector` when a `push_back` API is needed) or fixed at runtime + init (use `FixedVector`). +- Listener / child-entity registration lists stored as `std::vector`; these have + a compile-time-known count and should use `cg.slot_counter()` plus + `StaticVector`. +- `std::vector` for a byte buffer that never grows: prefer + `std::unique_ptr` or `std::array`. +- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16 + elements): a `std::vector` of a small struct with linear search is lighter. +- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided. +- `std::string` storing a value set once from config: prefer `StringRef` (the + literal already lives in flash). +- `std::string` / `std::to_string` / string-returning helpers on hot paths where + a buffer or view API exists. + +## C++ conventions + +- Include what you use: a file referencing a symbol must include the header that + declares it, even if it currently arrives transitively. New or changed uses of + a symbol need the matching include. +- Prefix all member access with `this->`. +- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase` + for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants, + trailing underscore on protected/private fields. +- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE` + (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`, + `FAIL`, or `OK` collide with SDK macros on some platforms and break the build. +- Prefer `const`/`enum` over `#define`; `#define` is only for conditional + compilation and code-generation sizes. +- Never call `millis()` in a `loop()` body; use + `App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop + period) does nothing. +- Pick the timing primitive by cadence: gated `loop()` under 250 ms, + `set_interval` at 500 ms and above. +- Do not override a base method to return the value it already returns (e.g. + `get_setup_priority()` returning `setup_priority::DATA`). +- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`. +- Required, invariant dependencies should be constructor parameters, not setters. +- Callback registration methods must be templated (`template`), not + typed as `std::function`, so lightweight forwarders avoid a heap allocation. +- Two-space indent, `using` over `typedef`, wrap at 120 columns. + +## Python conventions + +- Type-annotate every new function signature (params and return), new dataclass + fields, and new module-level variables. Import `ConfigType` from + `esphome.types`. +- Use the walrus operator to avoid a double lookup, e.g. + `if (blah := config.get(CONF_BLAH)) is not None:`. +- Reuse existing validators from `config_validation.py` (`cv.rename_key`, + `cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling. +- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in + the component's own `.py`, or in `esphome/components/const/__init__.py` when + shared. The same constant defined in three or more component files fails CI. +- State that must persist during code generation goes in `CORE.data` namespaced + under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals. +- Prefer callback-based triggers via `build_callback_automation()`; only use a + `Trigger` subclass when the forwarder needs mutable state. + +## Testing and coverage + +- New and changed lines and branches need test coverage, including defensive + early-returns, error paths, and no-op guards. A mocked-out function is not + covered; exercise the real call path too. +- Component YAML tests live in `tests/components//`. Never define + buses (uart, i2c, spi, modbus) directly in a test file: pull them from + `tests/test_build_components/common/` through dict-style `packages:` so CI can + group builds. List-style packages or top-level merge keys block grouping. +- Config-only checks use the `validate.*.yaml` prefix; compiled checks use + `test.*.yaml`. + +## Breaking changes and public API + +- Base classes under `esphome/core/` and documented config options are public + API. Undocumented `public` members of a component are internal. +- A breaking change needs justification, a migration path in the PR description, + and a deprecation window where feasible (`ESPDEPRECATED` in C++, + `cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected + lambda signature is not a breaking change. + +## Process and PR hygiene + +- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`) + or `[core]` for shared code. +- Prose in docs, comments, and commit messages should be plain English. Keep + inline comments short and only where the code is not self-explanatory; do not + restate what the code says. +- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds + `CODEOWNERS` entries for a new component. From a00d241cad0e39f54b045ce7994d4b1b0c506e74 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:08:56 -0400 Subject: [PATCH 320/759] [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418) Co-authored-by: Claude Fable 5.1 --- esphome/components/esp32/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ae1f821c178..e2e40f21bdc 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2792,6 +2792,11 @@ async def to_code(config): config.get(CONF_ENGINEERING_SAMPLE, False), ) + # ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not + # understand %zu or %lld and crashes on any %s that follows one. + if variant == VARIANT_ESP32C2: + add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False) + # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, # and for PSRAM users saves significant IRAM by keeping C library functions in ROM. From 7a27d5dde1a3b2a126e65118a37b1ee12adf41eb Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:45:23 +0100 Subject: [PATCH 321/759] [ci] Refresh integration test durations (#19435) --- .../integration_test_durations.json | 305 +++++++++--------- 1 file changed, 153 insertions(+), 152 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 16748b4537e..ea9885872d5 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,155 +1,156 @@ { - "tests/integration/test_action_concurrent_reentry.py": 34.72, - "tests/integration/test_addressable_light_transition.py": 33.71, - "tests/integration/test_alarm_control_panel_state_transitions.py": 38.96, - "tests/integration/test_api_action_metadata.py": 33.36, - "tests/integration/test_api_action_responses.py": 26.22, - "tests/integration/test_api_action_timeout.py": 25.41, - "tests/integration/test_api_conditional_memory.py": 20.74, - "tests/integration/test_api_custom_services.py": 23.21, - "tests/integration/test_api_get_time_response_timezone.py": 25.04, - "tests/integration/test_api_homeassistant.py": 24.18, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86, - "tests/integration/test_api_list_entities_backpressure.py": 18.8, - "tests/integration/test_api_message_size_batching.py": 28.8, - "tests/integration/test_api_reboot_timeout.py": 9.47, - "tests/integration/test_api_string_lambda.py": 16.88, - "tests/integration/test_api_vv_logging.py": 17.99, - "tests/integration/test_api_zero_psk_provisioning.py": 47.07, - "tests/integration/test_areas_and_devices.py": 20.77, - "tests/integration/test_automation_wait_actions.py": 20.07, - "tests/integration/test_automations.py": 27.41, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54, - "tests/integration/test_binary_sensor_invalidate_state.py": 16.09, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7, - "tests/integration/test_build_info.py": 18.07, - "tests/integration/test_camera_mock.py": 20.44, - "tests/integration/test_climate_control_action.py": 27.97, - "tests/integration/test_climate_custom_modes.py": 26.77, - "tests/integration/test_continuation_actions.py": 12.09, - "tests/integration/test_cover_control_action.py": 19.77, - "tests/integration/test_crc8_helper.py": 12.64, - "tests/integration/test_device_id_in_state.py": 63.19, - "tests/integration/test_duplicate_entities.py": 29.26, - "tests/integration/test_entity_icon.py": 34.95, - "tests/integration/test_fan_turn_on_action.py": 25.98, - "tests/integration/test_fnv1_hash_object_id.py": 4.85, - "tests/integration/test_fnv1a_hash.py": 5.14, - "tests/integration/test_gpio_expander_cache.py": 21.0, - "tests/integration/test_host_logger_thread_safety.py": 17.51, - "tests/integration/test_host_mode_basic.py": 21.2, - "tests/integration/test_host_mode_batch_delay.py": 26.68, - "tests/integration/test_host_mode_climate_basic_state.py": 18.04, - "tests/integration/test_host_mode_climate_control.py": 29.64, - "tests/integration/test_host_mode_empty_string_options.py": 28.8, - "tests/integration/test_host_mode_entity_fields.py": 28.68, - "tests/integration/test_host_mode_fan_preset.py": 16.98, - "tests/integration/test_host_mode_many_entities.py": 39.8, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12, - "tests/integration/test_host_mode_noise_encryption.py": 52.53, - "tests/integration/test_host_mode_reconnect.py": 14.48, - "tests/integration/test_host_mode_sensor.py": 17.38, - "tests/integration/test_host_ota.py": 94.96, - "tests/integration/test_host_preferences.py": 27.11, - "tests/integration/test_host_preferences_suspend_resume.py": 21.48, - "tests/integration/test_improv_serial_uart.py": 19.43, - "tests/integration/test_large_message_batching.py": 25.67, - "tests/integration/test_legacy_area.py": 15.59, - "tests/integration/test_legacy_climate_compat.py": 18.59, - "tests/integration/test_legacy_fan_compat.py": 18.34, - "tests/integration/test_light_automations.py": 16.74, - "tests/integration/test_light_binary_effect_off_phase.py": 57.7, - "tests/integration/test_light_calls.py": 25.88, - "tests/integration/test_light_constant_brightness.py": 22.32, - "tests/integration/test_light_control_action.py": 18.15, - "tests/integration/test_light_dim_relative_action.py": 30.35, - "tests/integration/test_light_effect_zero_brightness.py": 18.38, - "tests/integration/test_light_initial_state.py": 23.93, - "tests/integration/test_light_toggle_action.py": 26.16, - "tests/integration/test_lock_automations.py": 34.13, - "tests/integration/test_logger_buffered_recursion_guard.py": 25.77, - "tests/integration/test_loop_disable_enable.py": 14.71, - "tests/integration/test_loop_interval_decoupling.py": 26.16, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47, - "tests/integration/test_lvgl_headless_render.py": 96.36, - "tests/integration/test_micros_to_millis.py": 28.76, - "tests/integration/test_multi_click_trigger.py": 19.8, - "tests/integration/test_multi_device_preferences.py": 38.85, - "tests/integration/test_noise_encryption_key_protection.py": 25.81, - "tests/integration/test_object_id_api_verification.py": 28.46, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14, - "tests/integration/test_object_id_no_friendly_name.py": 18.82, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67, - "tests/integration/test_online_image_bmp.py": 7.41, - "tests/integration/test_oversized_payloads.py": 59.52, - "tests/integration/test_preference_key_stability.py": 27.24, - "tests/integration/test_runtime_stats.py": 20.53, - "tests/integration/test_safe_mode_loop_runs.py": 10.17, - "tests/integration/test_scheduler_blocking_warning.py": 51.45, - "tests/integration/test_scheduler_bulk_cleanup.py": 22.59, - "tests/integration/test_scheduler_defer_cancel.py": 17.64, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.61, - "tests/integration/test_scheduler_defer_fifo_simple.py": 24.73, - "tests/integration/test_scheduler_defer_stress.py": 23.91, - "tests/integration/test_scheduler_heap_stress.py": 25.77, - "tests/integration/test_scheduler_internal_id_no_collision.py": 19.83, - "tests/integration/test_scheduler_interval_reschedule.py": 23.4, - "tests/integration/test_scheduler_interval_zero_coerced.py": 5.11, - "tests/integration/test_scheduler_null_name.py": 17.36, - "tests/integration/test_scheduler_numeric_id_test.py": 20.81, - "tests/integration/test_scheduler_pool.py": 17.42, - "tests/integration/test_scheduler_rapid_cancellation.py": 25.28, - "tests/integration/test_scheduler_recursive_timeout.py": 16.48, - "tests/integration/test_scheduler_removed_item_race.py": 16.14, - "tests/integration/test_scheduler_self_keyed.py": 26.19, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26, - "tests/integration/test_scheduler_string_test.py": 27.09, - "tests/integration/test_script_array_params.py": 3.6, - "tests/integration/test_script_delay_params.py": 24.74, - "tests/integration/test_script_queued.py": 17.21, - "tests/integration/test_script_queued_idle_loop.py": 3.4, - "tests/integration/test_script_wait_on_boot.py": 23.7, - "tests/integration/test_sdl_headless_screenshot.py": 19.53, - "tests/integration/test_select_stringref_trigger.py": 18.93, - "tests/integration/test_sensor_filters_delta.py": 20.85, - "tests/integration/test_sensor_filters_ring_buffer.py": 16.82, - "tests/integration/test_sensor_filters_sliding_window.py": 54.78, - "tests/integration/test_sensor_filters_value_list.py": 19.46, - "tests/integration/test_sensor_timeout_filter.py": 18.39, - "tests/integration/test_set_internal_at_boot.py": 21.69, - "tests/integration/test_snapshot_display.py": 12.64, - "tests/integration/test_socket_wake_gate_tcp.py": 13.08, - "tests/integration/test_status_flags.py": 29.54, - "tests/integration/test_strftime_to.py": 25.62, - "tests/integration/test_syslog.py": 16.25, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18, + "tests/integration/test_action_concurrent_reentry.py": 33.88, + "tests/integration/test_addressable_light_transition.py": 25.37, + "tests/integration/test_alarm_control_panel_state_transitions.py": 29.76, + "tests/integration/test_api_action_metadata.py": 25.54, + "tests/integration/test_api_action_responses.py": 21.15, + "tests/integration/test_api_action_timeout.py": 37.89, + "tests/integration/test_api_conditional_memory.py": 13.21, + "tests/integration/test_api_custom_services.py": 18.72, + "tests/integration/test_api_get_time_response_timezone.py": 19.55, + "tests/integration/test_api_homeassistant.py": 32.09, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04, + "tests/integration/test_api_list_entities_backpressure.py": 14.96, + "tests/integration/test_api_message_size_batching.py": 22.77, + "tests/integration/test_api_reboot_timeout.py": 20.76, + "tests/integration/test_api_string_lambda.py": 35.07, + "tests/integration/test_api_vv_logging.py": 17.17, + "tests/integration/test_api_zero_psk_provisioning.py": 27.91, + "tests/integration/test_areas_and_devices.py": 23.45, + "tests/integration/test_automation_wait_actions.py": 21.21, + "tests/integration/test_automations.py": 30.73, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97, + "tests/integration/test_binary_sensor_invalidate_state.py": 17.7, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43, + "tests/integration/test_build_info.py": 23.12, + "tests/integration/test_camera_mock.py": 21.23, + "tests/integration/test_climate_control_action.py": 26.55, + "tests/integration/test_climate_custom_modes.py": 24.85, + "tests/integration/test_continuation_actions.py": 14.94, + "tests/integration/test_cover_control_action.py": 25.35, + "tests/integration/test_crc8_helper.py": 19.94, + "tests/integration/test_device_id_in_state.py": 57.04, + "tests/integration/test_duplicate_entities.py": 17.91, + "tests/integration/test_entity_icon.py": 23.81, + "tests/integration/test_fan_turn_on_action.py": 14.88, + "tests/integration/test_fnv1_hash_object_id.py": 21.14, + "tests/integration/test_fnv1a_hash.py": 16.96, + "tests/integration/test_gpio_expander_cache.py": 16.1, + "tests/integration/test_host_logger_thread_safety.py": 17.96, + "tests/integration/test_host_mode_basic.py": 8.17, + "tests/integration/test_host_mode_batch_delay.py": 18.78, + "tests/integration/test_host_mode_climate_basic_state.py": 28.88, + "tests/integration/test_host_mode_climate_control.py": 20.11, + "tests/integration/test_host_mode_empty_string_options.py": 21.1, + "tests/integration/test_host_mode_entity_fields.py": 22.3, + "tests/integration/test_host_mode_fan_preset.py": 18.12, + "tests/integration/test_host_mode_many_entities.py": 47.88, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94, + "tests/integration/test_host_mode_noise_encryption.py": 30.68, + "tests/integration/test_host_mode_reconnect.py": 4.8, + "tests/integration/test_host_mode_sensor.py": 17.36, + "tests/integration/test_host_ota.py": 94.88, + "tests/integration/test_host_preferences.py": 20.49, + "tests/integration/test_host_preferences_suspend_resume.py": 24.17, + "tests/integration/test_improv_serial_uart.py": 22.53, + "tests/integration/test_large_message_batching.py": 17.54, + "tests/integration/test_legacy_area.py": 23.03, + "tests/integration/test_legacy_climate_compat.py": 19.3, + "tests/integration/test_legacy_fan_compat.py": 14.69, + "tests/integration/test_light_automations.py": 21.44, + "tests/integration/test_light_binary_effect_off_phase.py": 29.84, + "tests/integration/test_light_calls.py": 26.72, + "tests/integration/test_light_constant_brightness.py": 29.08, + "tests/integration/test_light_control_action.py": 22.44, + "tests/integration/test_light_dim_relative_action.py": 16.57, + "tests/integration/test_light_effect_zero_brightness.py": 32.2, + "tests/integration/test_light_initial_state.py": 18.42, + "tests/integration/test_light_toggle_action.py": 16.23, + "tests/integration/test_lock_automations.py": 28.17, + "tests/integration/test_logger_buffered_recursion_guard.py": 23.52, + "tests/integration/test_loop_disable_enable.py": 19.4, + "tests/integration/test_loop_interval_decoupling.py": 27.32, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47, + "tests/integration/test_lvgl_headless_render.py": 58.56, + "tests/integration/test_micros_to_millis.py": 21.91, + "tests/integration/test_multi_click_trigger.py": 25.74, + "tests/integration/test_multi_device_preferences.py": 24.1, + "tests/integration/test_noise_encryption_key_protection.py": 22.54, + "tests/integration/test_object_id_api_verification.py": 14.59, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88, + "tests/integration/test_object_id_no_friendly_name.py": 40.23, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62, + "tests/integration/test_online_image_bmp.py": 8.46, + "tests/integration/test_oversized_payloads.py": 41.29, + "tests/integration/test_preference_key_stability.py": 33.97, + "tests/integration/test_runtime_stats.py": 23.77, + "tests/integration/test_safe_mode_loop_runs.py": 7.96, + "tests/integration/test_scheduler_blocking_warning.py": 45.2, + "tests/integration/test_scheduler_bulk_cleanup.py": 16.2, + "tests/integration/test_scheduler_defer_cancel.py": 19.87, + "tests/integration/test_scheduler_defer_cancel_regular.py": 15.93, + "tests/integration/test_scheduler_defer_fifo_simple.py": 14.12, + "tests/integration/test_scheduler_defer_stress.py": 24.55, + "tests/integration/test_scheduler_heap_stress.py": 19.84, + "tests/integration/test_scheduler_internal_id_no_collision.py": 18.42, + "tests/integration/test_scheduler_interval_reschedule.py": 22.37, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.69, + "tests/integration/test_scheduler_null_name.py": 22.08, + "tests/integration/test_scheduler_numeric_id_test.py": 18.45, + "tests/integration/test_scheduler_pool.py": 16.04, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.32, + "tests/integration/test_scheduler_recursive_timeout.py": 24.06, + "tests/integration/test_scheduler_removed_item_race.py": 24.2, + "tests/integration/test_scheduler_self_keyed.py": 19.48, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9, + "tests/integration/test_scheduler_string_test.py": 18.13, + "tests/integration/test_script_array_params.py": 8.06, + "tests/integration/test_script_delay_params.py": 13.13, + "tests/integration/test_script_queued.py": 17.67, + "tests/integration/test_script_queued_idle_loop.py": 16.33, + "tests/integration/test_script_wait_on_boot.py": 14.01, + "tests/integration/test_sdl_headless_screenshot.py": 12.91, + "tests/integration/test_select_stringref_trigger.py": 17.66, + "tests/integration/test_sensor_filters_delta.py": 25.94, + "tests/integration/test_sensor_filters_ring_buffer.py": 17.25, + "tests/integration/test_sensor_filters_sliding_window.py": 44.9, + "tests/integration/test_sensor_filters_value_list.py": 16.0, + "tests/integration/test_sensor_raw_state.py": 42.47, + "tests/integration/test_sensor_timeout_filter.py": 23.58, + "tests/integration/test_set_internal_at_boot.py": 21.55, + "tests/integration/test_snapshot_display.py": 7.58, + "tests/integration/test_socket_wake_gate_tcp.py": 15.76, + "tests/integration/test_status_flags.py": 37.76, + "tests/integration/test_strftime_to.py": 19.69, + "tests/integration/test_syslog.py": 21.44, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98, "tests/integration/test_template_climate_basic.py": 20.51, - "tests/integration/test_template_climate_custom_modes.py": 27.73, - "tests/integration/test_template_climate_nonoptimistic.py": 26.35, - "tests/integration/test_template_climate_on_control_ordering.py": 26.55, - "tests/integration/test_template_climate_publish_all_fields.py": 17.59, - "tests/integration/test_template_climate_sensor_push.py": 22.04, - "tests/integration/test_template_climate_set_actions.py": 16.82, - "tests/integration/test_template_climate_two_point_temperature.py": 25.13, - "tests/integration/test_template_text_save.py": 25.36, - "tests/integration/test_text_command.py": 18.79, - "tests/integration/test_text_sensor_raw_state.py": 17.07, - "tests/integration/test_uart_mock_ld2410.py": 59.58, - "tests/integration/test_uart_mock_ld2412.py": 59.4, - "tests/integration/test_uart_mock_ld2420.py": 45.27, - "tests/integration/test_uart_mock_ld2450.py": 27.96, - "tests/integration/test_uart_mock_modbus.py": 562.45, - "tests/integration/test_udp.py": 7.48, - "tests/integration/test_use_address_runtime.py": 17.27, - "tests/integration/test_valve_control_action.py": 18.33, - "tests/integration/test_varint_five_byte_device_id.py": 17.59, - "tests/integration/test_wait_until_mid_loop_timing.py": 16.93, - "tests/integration/test_wait_until_on_boot.py": 19.96, - "tests/integration/test_wait_until_ordering.py": 16.19, - "tests/integration/test_wait_until_reentrant_restart.py": 23.67, - "tests/integration/test_wake_loop_forces_phase_b.py": 17.58, - "tests/integration/test_water_heater_template.py": 21.96 + "tests/integration/test_template_climate_custom_modes.py": 16.94, + "tests/integration/test_template_climate_nonoptimistic.py": 19.28, + "tests/integration/test_template_climate_on_control_ordering.py": 15.47, + "tests/integration/test_template_climate_publish_all_fields.py": 18.9, + "tests/integration/test_template_climate_sensor_push.py": 14.96, + "tests/integration/test_template_climate_set_actions.py": 16.04, + "tests/integration/test_template_climate_two_point_temperature.py": 23.72, + "tests/integration/test_template_text_save.py": 15.49, + "tests/integration/test_text_command.py": 14.21, + "tests/integration/test_text_sensor_raw_state.py": 16.32, + "tests/integration/test_uart_mock_ld2410.py": 44.46, + "tests/integration/test_uart_mock_ld2412.py": 85.51, + "tests/integration/test_uart_mock_ld2420.py": 33.99, + "tests/integration/test_uart_mock_ld2450.py": 25.3, + "tests/integration/test_uart_mock_modbus.py": 522.13, + "tests/integration/test_udp.py": 13.7, + "tests/integration/test_use_address_runtime.py": 33.8, + "tests/integration/test_valve_control_action.py": 13.0, + "tests/integration/test_varint_five_byte_device_id.py": 25.11, + "tests/integration/test_wait_until_mid_loop_timing.py": 22.73, + "tests/integration/test_wait_until_on_boot.py": 10.45, + "tests/integration/test_wait_until_ordering.py": 3.64, + "tests/integration/test_wait_until_reentrant_restart.py": 18.73, + "tests/integration/test_wake_loop_forces_phase_b.py": 22.85, + "tests/integration/test_water_heater_template.py": 31.06 } From 5764a72f1b0159ff1aa11d5d6b2ec7b840fcf540 Mon Sep 17 00:00:00 2001 From: Iago Veiga Date: Mon, 21 Sep 2026 23:26:06 +0200 Subject: [PATCH 322/759] [syslog] Add configurable RFC 3164 and RFC 5424 formats (#18993) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/syslog/__init__.py | 12 +++++- esphome/components/syslog/esphome_syslog.cpp | 38 ++++++++++++------- esphome/components/syslog/esphome_syslog.h | 7 ++++ .../syslog/test-rfc5424.esp32-idf.yaml | 8 ++++ tests/integration/fixtures/syslog.yaml | 1 + tests/integration/test_syslog.py | 37 +++++++++++++----- 6 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 tests/components/syslog/test-rfc5424.esp32-idf.yaml diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index bf7fe9a3072..e25567c783d 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list from esphome.components.time import RealTimeClock from esphome.components.udp import CONF_UDP_ID import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID +from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID from esphome.cpp_types import Component, Parented from esphome.types import ConfigType @@ -14,6 +14,12 @@ DEPENDENCIES = ["udp", "logger", "time"] syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) +SyslogFormat = syslog_ns.enum("SyslogFormat") + +SYSLOG_FORMATS = { + "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, + "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, +} CONF_STRIP = "strip" CONF_FACILITY = "facility" @@ -25,6 +31,9 @@ CONFIG_SCHEMA = udp.UDP_SCHEMA.extend( cv.Optional(CONF_LEVEL, default="DEBUG"): is_log_level, cv.Optional(CONF_STRIP, default=True): cv.boolean, cv.Optional(CONF_FACILITY, default=16): cv.int_range(0, 23), + cv.Optional(CONF_FORMAT, default="RFC3164"): cv.enum( + SYSLOG_FORMATS, upper=True + ), } ) @@ -41,3 +50,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, parent) cg.add(var.set_strip(config[CONF_STRIP])) cg.add(var.set_facility(config[CONF_FACILITY])) + cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 790d08ffa6d..a40829f3153 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -49,27 +49,37 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP) char packet[508]; size_t offset = 0; - size_t remaining = sizeof(packet); - // Write PRI - abort if this fails as packet would be malformed offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri); if (offset == 0) { return; // PRI always produces at least "<0>" (3 chars), so 0 means error } - remaining -= offset; - - // Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails) auto now = this->time_->now(); - size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0; - if (ts_written > 0) { - offset += ts_written; - } else if (remaining > 0) { - packet[offset++] = '-'; - } + if (this->format_ == SYSLOG_FORMAT_RFC5424) { + offset = buf_append_str(packet, sizeof(packet), offset, "1 "); - // Write hostname, tag, and message - offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len, - message); + char timestamp[32]; + size_t timestamp_len = now.is_valid() ? now.strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z") : 0; + if (timestamp_len == 24) { + // ESPTime formats the numeric offset as +HHMM. RFC 3339 requires +HH:MM. + timestamp[25] = '\0'; + timestamp[24] = timestamp[23]; + timestamp[23] = timestamp[22]; + timestamp[22] = ':'; + offset = buf_append_printf(packet, sizeof(packet), offset, "%s", timestamp); + } else { + offset = buf_append_str(packet, sizeof(packet), offset, "-"); + } + offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s - - - %.*s", App.get_name().c_str(), tag, + (int) len, message); + } else { + // RFC 3164 has no NILVALUE. If the clock is invalid, omit TIMESTAMP so a relay can add it. + if (now.is_valid()) { + offset += now.strftime(packet + offset, sizeof(packet) - offset, "%b %e %H:%M:%S "); + } + offset = buf_append_printf(packet, sizeof(packet), offset, "%s %s: %.*s", App.get_name().c_str(), tag, (int) len, + message); + } // Clamp to exclude null terminator position if buffer was filled if (offset >= sizeof(packet)) { offset = sizeof(packet) - 1; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index 4a76f9ac627..f4b2a7dd62f 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,6 +7,11 @@ #ifdef USE_NETWORK namespace esphome::syslog { +enum SyslogFormat : uint8_t { + SYSLOG_FORMAT_RFC3164, + SYSLOG_FORMAT_RFC5424, +}; + class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} @@ -14,6 +19,7 @@ class Syslog final : public Component, public Parented { void on_log(uint8_t level, const char *tag, const char *message, size_t message_len); void set_strip(bool strip) { this->strip_ = strip; } void set_facility(int facility) { this->facility_ = facility; } + void set_format(SyslogFormat format) { this->format_ = format; } protected: int log_level_; @@ -21,6 +27,7 @@ class Syslog final : public Component, public Parented { time::RealTimeClock *time_; bool strip_{true}; int facility_{16}; + SyslogFormat format_{SYSLOG_FORMAT_RFC3164}; }; } // namespace esphome::syslog #endif diff --git a/tests/components/syslog/test-rfc5424.esp32-idf.yaml b/tests/components/syslog/test-rfc5424.esp32-idf.yaml new file mode 100644 index 00000000000..3a49aeb57d7 --- /dev/null +++ b/tests/components/syslog/test-rfc5424.esp32-idf.yaml @@ -0,0 +1,8 @@ +<<: !include common.yaml + +syslog: + port: 514 + strip: true + level: info + facility: 16 + format: RFC5424 diff --git a/tests/integration/fixtures/syslog.yaml b/tests/integration/fixtures/syslog.yaml index df376087e32..dd48938a0a1 100644 --- a/tests/integration/fixtures/syslog.yaml +++ b/tests/integration/fixtures/syslog.yaml @@ -41,3 +41,4 @@ syslog: level: DEBUG strip: true facility: 16 + format: SYSLOG_FORMAT_PLACEHOLDER diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index 0567164805e..293824e2f21 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -31,14 +31,25 @@ class ParsedSyslogMessage(TypedDict): # RFC 3164 syslog message pattern: # TIMESTAMP HOSTNAME TAG: MESSAGE # Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running... -SYSLOG_PATTERN = re.compile( +RFC3164_PATTERN = re.compile( r"<(\d+)>" # PRI (priority = facility * 8 + severity) - r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-") - r" (\S+)" # HOSTNAME + r"(?:(\S+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP + r"(\S+)" # HOSTNAME r" (\S+):" # TAG r" (.*)" # MESSAGE ) +# RFC 5424 syslog message pattern: +# VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG +RFC5424_PATTERN = re.compile( + r"<(\d+)>1 " + r"(\S+) " + r"(\S+) " + r"(\S+) " + r"- - - " + r"(.*)" +) + @dataclass class SyslogReceiver: @@ -123,9 +134,10 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]: sock.close() -def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: +def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None: """Parse a syslog message and return its components.""" - match = SYSLOG_PATTERN.match(msg) + pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN + match = pattern.fullmatch(msg) if not match: return None pri, timestamp, hostname, tag, message = match.groups() @@ -137,7 +149,7 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: pri=pri_val, facility=facility, severity=severity, - timestamp=timestamp, + timestamp=timestamp or "", hostname=hostname, tag=tag, message=message, @@ -145,15 +157,18 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None: @pytest.mark.asyncio +@pytest.mark.parametrize("format_", ["RFC3164", "RFC5424"]) async def test_syslog( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, + format_: str, ) -> None: """Test syslog component sends properly formatted messages.""" async with syslog_udp_listener() as (udp_port, receiver): # Replace the placeholder port in the config config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port)) + config = config.replace("SYSLOG_FORMAT_PLACEHOLDER", format_) async with run_compiled(config), api_client_connected() as client: # Verify device is running @@ -176,7 +191,7 @@ async def test_syslog( # Parse and validate all messages parsed_messages: list[ParsedSyslogMessage] = [] for msg in receiver.messages: - parsed = parse_syslog_message(msg) + parsed = parse_syslog_message(msg, format_) if parsed: parsed_messages.append(parsed) @@ -204,12 +219,16 @@ async def test_syslog( f"Unexpected hostname: {parsed['hostname']}" ) - # Validate timestamp format (BSD or NILVALUE) - if parsed["timestamp"] != "-": + if format_ == "RFC3164" and parsed["timestamp"]: assert re.match( r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}", parsed["timestamp"], ), f"Invalid timestamp format: {parsed['timestamp']}" + elif format_ == "RFC5424" and parsed["timestamp"] != "-": + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}", + parsed["timestamp"], + ), f"Invalid timestamp format: {parsed['timestamp']}" # Verify we see different severity levels in the logs severities_seen = {p["severity"] for p in parsed_messages} From 81dd57b402b2d98c4a2a93bfb1f43337415ffaa5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:00 +0100 Subject: [PATCH 323/759] Bump platformdirs from 4.11.8 to 4.11.10 (#19443) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 15ee7af7c86..cb5a725676a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.8 # native esp-idf toolchain global cache dir +platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 8f3f25b8325705554c099a964dce0dc4f4b40dbf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:22 +0100 Subject: [PATCH 324/759] Bump codecov/codecov-action from 7.1.0 to 7.1.1 (#19446) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb461678cb0..08cd852f022 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,7 +321,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -350,7 +350,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: run_command: empty-upload force: true From cd012b57ed9e130ba46e30023253e6c337853275 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:36 +0100 Subject: [PATCH 325/759] Bump ruff from 0.16.7 to 0.16.8 (#19441) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c7600ec127..a0241857688 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.7 + rev: v0.16.8 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 81208d7cb71..95e05d1be9c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 3b4a6f21f9f27cccba5b7f7f504d51d4cb09c8a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:41:52 +0100 Subject: [PATCH 326/759] Bump filelock from 3.32.6 to 4.0.0 (#19442) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cb5a725676a..abff6dfa348 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==3.32.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 352121d60f00464e0ff6b7feef31f521634176ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:02 +0100 Subject: [PATCH 327/759] Bump esphome/workflows/.github/workflows/lock.yml from 2026.8.1 to 2026.9.0 (#19448) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index e09e9bf2d17..74ac5fe05a3 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 + uses: esphome/workflows/.github/workflows/lock.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0 From cd50f949aee638d841934643efb9487d36710154 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:25 +0100 Subject: [PATCH 328/759] Bump github/codeql-action/analyze from 4.38.0 to 4.38.1 (#19447) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0daae69ccf0..6f1a6efc66f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:${{matrix.language}}" From 632f63b34bebc5a493990879ed5747a8f8fc7b5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:33 +0100 Subject: [PATCH 329/759] Bump github/codeql-action/init from 4.38.0 to 4.38.1 (#19445) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6f1a6efc66f..efa6ccf5149 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From a78e955691e054983c92f925584795d31ff5e7cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:42:45 +0100 Subject: [PATCH 330/759] Bump esphome/workflows/.github/workflows/stale.yml from a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 to cc3e76de337dc59bc1cba8da58d963cd23b873f1 (#19444) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 38d2418ac62..43b7e6663d6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main + uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From f4aaa595f80e4529f4421f512fc7d781fd9a6dba Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:59:19 +0000 Subject: [PATCH 331/759] Bump aioesphomeapi from 46.4.1 to 46.5.0 (#19463) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index abff6dfa348..79bf05db271 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.4.0 click==8.3.3 -aioesphomeapi==46.4.1 +aioesphomeapi==46.5.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 44cf2b594a9d5cf3a534945a5449bf2d9174bab3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:00 +0100 Subject: [PATCH 332/759] Bump filelock from 4.0.0 to 4.0.1 (#19464) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 79bf05db271..ef1b00a73aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.10 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver -filelock==4.0.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From 2b805b897b350b679f7e34cf6314c776cc6899d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:08:24 +0100 Subject: [PATCH 333/759] Bump platformdirs from 4.11.10 to 4.11.11 (#19465) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ef1b00a73aa..fd693533452 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ bleak==3.0.2 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.10 # native esp-idf toolchain global cache dir +platformdirs==4.11.11 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 79532f2a857b070e7dfcf97a04b4ac9102451254 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:48:50 +0200 Subject: [PATCH 334/759] [zigbee] Require mbedTLS extras for Zigbee crypto (#19457) --- esphome/components/zigbee/zigbee_esp32.py | 7 +++++ .../esp32/config/tls_zigbee_c6.yaml | 11 ++++++++ tests/component_tests/esp32/test_esp32.py | 27 ++++++++++++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp32/config/tls_zigbee_c6.yaml diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 57fa3b2a007..9aae8d4557d 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_tls_extras, require_vfs_select, ) import esphome.config_validation as cv @@ -289,6 +290,12 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": ref="2.0.4", ) + # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Keep the esp32 component from trimming them out of mbedTLS. + require_mbedtls_tls_extras( + ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") + ) + if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present include_builtin_idf_component("esp_coex") diff --git a/tests/component_tests/esp32/config/tls_zigbee_c6.yaml b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml new file mode 100644 index 00000000000..3f6904bd983 --- /dev/null +++ b/tests/component_tests/esp32/config/tls_zigbee_c6.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +zigbee: + model: zigbee_test + power_source: MAINS_SINGLE_PHASE diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3ba819dee7b..08f5dcc8227 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1488,7 +1488,7 @@ def test_mbedtls_tls_trim_sdkconfig( assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras} -_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} +_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"} def test_mbedtls_tls_openthread_keeps_only_what_it_uses( @@ -1500,7 +1500,19 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: - assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False) + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + + +def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False) + for name in MBEDTLS_TLS_EXTRA_OPTIONS: + assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) def test_mbedtls_tls_user_sdkconfig_wins( @@ -1529,7 +1541,16 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True - assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS + + +def test_mbedtls_tls_zigbee_requires_extras( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + generate_main(component_config_path("tls_zigbee_c6.yaml")) + assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e76edeb19ea50148de25238842b639039fa040d6 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Tue, 22 Sep 2026 17:15:38 -0700 Subject: [PATCH 335/759] [yaml_util][substitutions] Repair bad tests (#19468) --- .../yaml_util/broken_includetest.yaml | 18 ------------ .../includes/broken_included.yaml.txt | 5 ---- .../fixtures/yaml_util/missing_comp.yaml | 12 -------- tests/unit_tests/test_substitutions.py | 28 +++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 24 +--------------- 5 files changed, 29 insertions(+), 58 deletions(-) delete mode 100644 tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml delete mode 100644 tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt delete mode 100644 tests/unit_tests/fixtures/yaml_util/missing_comp.yaml diff --git a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml b/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml deleted file mode 100644 index a49ae706a40..00000000000 --- a/tests/unit_tests/fixtures/yaml_util/broken_includetest.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -substitutions: - name: original - -wifi: !include - file: includes/broken_included.yaml.txt - vars: - name: my_custom_ssid - -esphome: - # should be substituted as 'original', - # not overwritten by vars in the !include above - name: ${name} - name_add_mac_suffix: true - libraries: !include {file: includes/list.yaml, vars: {var1: Wire}} - -esp8266: - board: !include {file: includes/scalar.yaml, vars: {var1: nodemcu}} diff --git a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt b/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt deleted file mode 100644 index 6e53395c865..00000000000 --- a/tests/unit_tests/fixtures/yaml_util/includes/broken_included.yaml.txt +++ /dev/null @@ -1,5 +0,0 @@ ---- -# yamllint disable-line - ssid: ${name} -# yamllint disable-line - fdf: error diff --git a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml b/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml deleted file mode 100644 index d065901ed97..00000000000 --- a/tests/unit_tests/fixtures/yaml_util/missing_comp.yaml +++ /dev/null @@ -1,12 +0,0 @@ -esphome: - name: test - -esp32: - board: esp32dev - -wifi: - ap: ~ - -image: - - id: its_a_bug - file: "mdi:bug" diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 73c6e496a9f..b94a70443ce 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -730,6 +730,34 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: processor.resolve_package(package_config, substitutions.ContextVars(), []) +def test_include_non_existent_file(tmp_path: Path) -> None: + """!include with a file that cannot be opened raises cv.Invalid.""" + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include non_existent_include_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'non_existent_include_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + +def test_include_broken_file(tmp_path: Path) -> None: + """!include with a file that cannot be parsed raises cv.Invalid.""" + broken_file = tmp_path / "broken_file.yaml" + broken_file.write_text("{garbage\n") + main_file = tmp_path / "main.yaml" + main_file.write_text("result: !include broken_file.yaml\n") + + config = yaml_util.load_yaml(main_file) + with pytest.raises( + cv.Invalid, match=r"Error including file 'broken_file.yaml'" + ) as exc_info: + substitutions.do_substitution_pass(config) + assert "main.yaml" in str(exc_info.value) + + def test_include_filename_substitution_undefined_var(tmp_path: Path) -> None: """!include with an undefined substitution variable raises cv.Invalid. diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 8e1f9c25c08..f3f73ea4dcd 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -54,34 +54,12 @@ def test_include_with_vars(fixture_path: Path) -> None: assert actual["wifi"]["ssid"] == "my_custom_ssid" -def test_loading_a_broken_yaml_file(fixture_path): - """Ensure we fallback to pure python to give good errors.""" - yaml_file = fixture_path / "yaml_util" / "broken_includetest.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "broken_included.yaml" in str(err) - - -def test_loading_a_yaml_file_with_a_missing_component(fixture_path): - """Ensure we show the filename for a yaml file with a missing component.""" - yaml_file = fixture_path / "yaml_util" / "missing_comp.yaml" - - try: - yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing_comp.yaml" in str(err) - - def test_loading_a_missing_file(fixture_path): """We throw EsphomeError when loading a missing file.""" yaml_file = fixture_path / "yaml_util" / "missing.yaml" - try: + with pytest.raises(EsphomeError, match=r"missing.yaml"): yaml_util.load_yaml(yaml_file) - except EsphomeError as err: - assert "missing.yaml" in str(err) def test_parsing_with_custom_loader(fixture_path): From 359eba714542e65a8383409c151019eaf237f90e Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Tue, 22 Sep 2026 21:33:30 -0300 Subject: [PATCH 336/759] [fujitsu_general] Use constexpr for the protocol constants (#19486) --- .../fujitsu_general/fujitsu_general.cpp | 90 +++++++++---------- .../fujitsu_general/fujitsu_general.h | 4 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index f801239153c..aca643defeb 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -11,78 +11,78 @@ namespace esphome::fujitsu_general { static const char *const TAG = "fujitsu_general.climate"; // Common header -const uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; -const uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; +constexpr uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE0 = 0x14; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE1 = 0x63; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE2 = 0x00; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE3 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_COMMON_BYTE4 = 0x10; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_BYTE = 5; // State message - temp & fan etc. -const uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; +constexpr uint8_t FUJITSU_GENERAL_STATE_MESSAGE_LENGTH = 16; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; // Util messages - off & eco etc. -const uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; -const uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; +constexpr uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; // State header -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; -const uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; +constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer -const uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; +constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; // Temperature -const uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; // Power on -const uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; -const uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; -const uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -const uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; -const uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; -const uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; -const uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; -const uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; +constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; +constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; // const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; // Swing -const uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; -const uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; -const uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; -const uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; -const uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; +constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; +constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; +constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -const uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; -const uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; -const uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; -const uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; -const uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; -const uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; +constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; +constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; +constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; +constexpr uint8_t FUJITSU_GENERAL_FAN_LOW = 0x03; +constexpr uint8_t FUJITSU_GENERAL_FAN_SILENT = 0x04; // TODO Outdoor Unit Low Noise // const uint8_t FUJITSU_GENERAL_OUTDOOR_UNIT_LOW_NOISE_BYTE14 = 0xA0; // const uint8_t FUJITSU_GENERAL_STATE_BYTE14 = 0x20; -const uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; -const uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; +constexpr uint16_t FUJITSU_GENERAL_HEADER_MARK = 3300; +constexpr uint16_t FUJITSU_GENERAL_HEADER_SPACE = 1600; -const uint16_t FUJITSU_GENERAL_BIT_MARK = 420; -const uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; -const uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; +constexpr uint16_t FUJITSU_GENERAL_BIT_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_ONE_SPACE = 1200; +constexpr uint16_t FUJITSU_GENERAL_ZERO_SPACE = 420; -const uint16_t FUJITSU_GENERAL_TRL_MARK = 420; -const uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; +constexpr uint16_t FUJITSU_GENERAL_TRL_MARK = 420; +constexpr uint16_t FUJITSU_GENERAL_TRL_SPACE = 8000; -const uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; +constexpr uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; void FujitsuGeneralClimate::transmit_state() { if (this->mode == climate::CLIMATE_MODE_OFF) { diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 8d2ec883da9..93363860d4e 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -7,8 +7,8 @@ namespace esphome::fujitsu_general { -const uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH -const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius +constexpr uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH +constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius // clang-format off /** From e8d3c06b60f5a44d6ae31a902cc2e4f5b055a98f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 10:36:42 +0100 Subject: [PATCH 337/759] [core][cover] Add register_apply_action for actions that only forward values (#19469) --- AGENTS.md | 14 ++ esphome/automation.py | 193 +++++++++++++++- esphome/components/cover/__init__.py | 113 +--------- esphome/components/cover/automation.h | 44 ---- esphome/components/template/cover/__init__.py | 39 +--- esphome/config_validation.py | 1 + esphome/core/automation.h | 9 +- esphome/core/base_automation.h | 15 ++ esphome/core/helpers.cpp | 9 + esphome/core/progmem.h | 8 + esphome/cpp_generator.py | 4 +- tests/components/template/common-base.yaml | 4 +- .../integration/test_cover_control_action.py | 4 +- tests/unit_tests/test_automation.py | 213 +++++++++++++++++- tests/unit_tests/test_cpp_generator.py | 9 + 15 files changed, 485 insertions(+), 194 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 448bf49114f..cf78daa5a37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,6 +443,20 @@ file does, and it is the authority when they disagree. The most useful starting Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + **Actions that only forward templatable values to their parent need no C++ class.** Register them + with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape. + ```python + automation.register_apply_action( + "my_component.set_gains", + schema, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + ) + ``` + The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover + the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with + `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. + * **Conditions:** ```cpp template class MyCondition : public Condition { diff --git a/esphome/automation.py b/esphome/automation.py index 3ffda50c812..373f924e4db 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,5 +1,8 @@ +from collections.abc import Callable from dataclasses import dataclass, field import logging +import string +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -18,15 +21,17 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import ( + FlashStringLiteral, LambdaExpression, MockObj, MockObjClass, TemplateArgsType, + call_lambda, ) from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType from esphome.util import Registry @@ -57,6 +62,7 @@ def maybe_conf(conf, *validators): with cv.remove_prepend_path([conf]): return validator({conf: value}) + validate.inner_schema = validator return validate @@ -207,6 +213,189 @@ validate_action_list = cv.validate_registry("action", ACTION_REGISTRY) validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) +ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) + + +def flash_string(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" + if CORE.is_esp8266: + return f"progmem_string({FlashStringLiteral(value)})" + return str(cg.safe_exp(value)) + + +@dataclass(frozen=True) +class ApplyCall: + """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. + + Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a + path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use + ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda bypasses + it. The statement is skipped when none of its keys is set, always emitted when it has no + keys, and a partial set is a config error. + """ + + target: str + args: tuple[tuple[Any, ...], ...] = () + + def __post_init__(self) -> None: + fields = [ + f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None + ] + if any(fields): + raise ValueError( + f"apply target {self.target!r}: only bare {{}} placeholders" + ) + if len(fields) != len(self.args): + raise ValueError( + f"apply target {self.target!r} has {len(fields)} " + f"placeholder(s) for {len(self.args)} config key(s)" + ) + if any(len(arg) not in (2, 3) for arg in self.args): + raise ValueError( + f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" + ) + + +@dataclass(frozen=True) +class ApplyField: + """One config key forwarded as ``target(value)``, or as statement ``target`` when it has ``{}``. + + Double a literal brace in a template. ``conf_key`` may be a path into nested sections. + ``type_`` may be a C++ type string using ``{parent}`` when the type is only known per + instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp`` + is not the right spelling (unit conversion belongs in the validator); a lambda bypasses it, + so the target must also take a plain ``type_``. An absent key emits nothing. + """ + + conf_key: str | tuple[str, ...] + target: str + type_: SafeExpType + const_fn: Callable[[ConfigType, Any], str] | None = None + + def call(self) -> ApplyCall: + target = self.target if "{}" in self.target else f"{self.target}({{}})" + return ApplyCall(target, ((self.conf_key, self.type_, self.const_fn),)) + + +def _config_lookup(config: ConfigType, key: str | tuple[str, ...]) -> Any: + if isinstance(key, str): + return config.get(key) + for part in key: + if (config := config.get(part)) is None: + return None + return config + + +def _dict_schema(schema: Any) -> Any: + """The dict-backed cv.Schema inside cv.All and maybe_* wrappers, or None; cv.Any is not inspected.""" + if isinstance(schema, dict): + return cv.Schema(schema) + if isinstance(getattr(schema, "schema", None), dict): + return schema + if isinstance(schema, cv.All): + inner = schema.validators + else: + inner = ( + getattr(schema, "inner_schema", None), + ) # maybe_conf / maybe_simple_value + for candidate in inner: + if candidate is not None and (found := _dict_schema(candidate)) is not None: + return found + return None + + +def _check_key_in_schema( + name: str, schema: Any, conf_key: str | tuple[str, ...] +) -> None: + """Reject a key path the schema does not have; a typo would otherwise be a silent no-op. + + Only dict-backed schemas, also inside cv.All and maybe_* wrappers, can be checked. + """ + for part in (conf_key,) if isinstance(conf_key, str) else conf_key: + if (schema := _dict_schema(schema)) is None: + return + markers = { + getattr(marker, "schema", marker): marker for marker in schema.schema + } + if part not in markers: + raise ValueError(f"{name}: config key {part!r} is not in the schema") + schema = schema.schema[markers[part]] + + +def register_apply_action( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, +) -> None: + """Register an action that only forwards config values to its parent, with no C++ class. + + Generates one stateless function for ``ApplyAction``: parent and constants are baked + in, lambdas are called inline with the trigger args. With ``call`` every statement targets + the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. + """ + statements_spec = [ + ( + c.target, + [(arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in c.args], + ) + for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) + ] + for _, members in statements_spec: + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + parent = f"::{await cg.get_variable(config[CONF_ID])}" + # Must match ApplyAction::ApplyFn exactly for the function pointer conversion. + lambda_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + receiver = "apply_call." if call else f"{parent}->" + statements: list[str] = [] + for target, members in statements_spec: + values = [_config_lookup(config, key) for key, _, _ in members] + if members and all(value is None for value in values): + continue + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda( + value, lambda_args, return_type=type_ + ) + exprs.append(str(call_lambda(inner))) + elif const_fn is not None: + exprs.append(const_fn(config, value)) + elif type_ is cg.std_string: + exprs.append(flash_string(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + statements.append(f"{receiver}{target.format(*exprs)};") + if call: + statements = [ + f"auto apply_call = {parent}->{call}();", + *statements, + "apply_call.perform();", + ] + apply_lambda = LambdaExpression( + ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ) + return cg.new_Pvariable(action_id, template_arg, apply_lambda) + + register_action(name, ApplyAction, schema, synchronous=True)(builder) + def validate_potentially_and_condition(value): if isinstance(value, list): diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 011b2c2f04c..05a9afad615 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,5 +1,3 @@ -from collections.abc import Callable -from dataclasses import dataclass import logging from esphome import automation @@ -38,14 +36,14 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -70,7 +68,6 @@ _LOGGER = logging.getLogger(__name__) cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) -CoverCall = cover_ns.class_("CoverCall") COVER_OPEN = cover_ns.COVER_OPEN COVER_CLOSED = cover_ns.COVER_CLOSED @@ -94,8 +91,6 @@ OpenAction = cover_ns.class_("OpenAction", automation.Action) CloseAction = cover_ns.class_("CloseAction", automation.Action) StopAction = cover_ns.class_("StopAction", automation.Action) ToggleAction = cover_ns.class_("ToggleAction", automation.Action) -ControlAction = cover_ns.class_("ControlAction", automation.Action) -CoverPublishAction = cover_ns.class_("CoverPublishAction", automation.Action) CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( @@ -319,107 +314,19 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@dataclass(frozen=True) -class ApplyField: - """One field in a folded-lambda action. - - `conf_key` is the YAML key looked up in `config`. When present, the - helper emits `statement_fn(target, value_expr)` into the lambda body. - `target` is whatever the statement function needs to identify the - field (typically a setter name like `"set_position"` or a struct - member like `"position"`). `type_` is the C++ return type for - `cg.process_lambda` when the value is a user lambda. - """ - - conf_key: str - target: str - type_: object - - -async def build_apply_lambda_action( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, - fields: tuple[ApplyField, ...], - prefix_args: list[tuple[object, str]], - statement_fn: Callable[[str, str], str], -) -> MockObj: - """Fold configured fields into a single stateless apply lambda action. - - Used by both `cover.control` and `cover.template.publish` (and shared - with the template/cover platform). Constants are emitted as flash - immediates; user lambdas are invoked inline so trigger args still flow. - Trigger arg types are normalized to `const std::remove_cvref_t &` - to match the ApplyFn signature for any T (value, ref, or const-ref). - """ - paren = await cg.get_variable(config[CONF_ID]) - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for field in fields: - if (value := config.get(field.conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, normalized_args, return_type=field.type_ - ) - value_expr = f"({inner})({fwd_args})" - else: - value_expr = str(cg.safe_exp(value)) - body_lines.append(statement_fn(field.target, value_expr)) - - apply_args = [ - *prefix_args, - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) - - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both dispatch to set_position. -_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = ( - ApplyField(CONF_STOP, "set_stop", cg.bool_), - ApplyField(CONF_STATE, "set_position", cg.float_), - ApplyField(CONF_POSITION, "set_position", cg.float_), - ApplyField(CONF_TILT, "set_tilt", cg.float_), +automation.register_apply_action( + "cover.control", + COVER_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + automation.ApplyField(CONF_TILT, "set_tilt", cg.float_), + call="make_call", ) -@automation.register_action( - "cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True -) -async def cover_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return await build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_CONTROL_FIELDS, - prefix_args=[(CoverCall.operator("ref"), "call")], - statement_fn=lambda setter, expr: f"call.{setter}({expr});", - ) - - COVER_CONDITION_SCHEMA = cv.maybe_simple_value( {cv.Required(CONF_ID): cv.use_id(Cover)}, key=CONF_ID ) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 0a5a447ab92..20f56e17538 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -46,50 +46,6 @@ template class ToggleAction final : public Action { Cover *cover_; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. Each action stores only one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. - -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); - ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->cover_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - -template class CoverPublishAction final : public Action { - public: - using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); - CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} - - void play(const Ts &...x) override { - this->apply_(this->cover_, x...); - this->cover_->publish_state(); - } - - protected: - Cover *cover_; - ApplyFn apply_; -}; - template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 0e6f96e9f5b..39df5affcf2 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -20,9 +20,6 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -120,17 +117,8 @@ async def to_code(config): # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both map to the position field. -_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( - cover.ApplyField(CONF_STATE, "position", cg.float_), - cover.ApplyField(CONF_POSITION, "position", cg.float_), - cover.ApplyField(CONF_TILT, "tilt", cg.float_), - cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), -) - - -@automation.register_action( +automation.register_apply_action( "cover.template.publish", - cover.CoverPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(cover.Cover), @@ -142,22 +130,11 @@ _COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField(CONF_TILT, "tilt = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", cover.CoverOperation + ), + automation.ApplyCall("publish_state()"), ) -async def cover_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - # Mutates Cover fields directly (no CoverCall) since publish is a state - # push, not a control request. - return await cover.build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_PUBLISH_FIELDS, - prefix_args=[(cover.Cover.operator("ptr"), "cover")], - statement_fn=lambda field, expr: f"cover->{field} = {expr};", - ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1623117a367..579da9a315e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2354,6 +2354,7 @@ def maybe_simple_value(*validators, **kwargs): return validator(value) return validator({key: value}) + validate.inner_schema = validator return validate diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 5f010521dce..b6058925b4a 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -317,13 +317,8 @@ template class TemplatableValue { case STATIC_STRING: return std::string(this->static_str_); #ifdef USE_ESP8266 - case FLASH_STRING: { - // PROGMEM pointer — must use _P functions to access on ESP8266 - size_t len = strlen_P(this->static_str_); - std::string result(len, '\0'); - memcpy_P(result.data(), this->static_str_, len); - return result; - } + case FLASH_STRING: + return progmem_string(reinterpret_cast(this->static_str_)); #endif case NONE: default: diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 999b38bd5cf..b0c4b649957 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome { @@ -252,6 +253,20 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; +/// Runs one codegen-generated function that has the parent and every field baked in, so the +/// action holds one pointer. Args pass by const reference so a std::string arg is never copied; +/// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. +template class ApplyAction final : public Action { + public: + using ApplyFn = void (*)(const std::remove_cvref_t &...); + explicit ApplyAction(ApplyFn apply) : apply_(apply) {} + + void play(const Ts &...x) override { this->apply_(x...); } + + protected: + ApplyFn apply_; +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 433d2547b0e..313daacbd0f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -20,6 +20,15 @@ namespace esphome { +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str) { + auto *src = reinterpret_cast(str); + std::string result(strlen_P(src), '\0'); + memcpy_P(result.data(), src, result.size()); + return result; +} +#endif + static const char *const TAG = "helpers"; __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index d349418d02e..992b3c0e92f 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "esphome/core/hal.h" // For PROGMEM definition @@ -54,6 +55,13 @@ using ProgmemStr = const char *; namespace esphome { +/// Copies a string stored with ESPHOME_F into a std::string. +#ifdef USE_ESP8266 +std::string progmem_string(ProgmemStr str); +#else +inline std::string progmem_string(ProgmemStr str) { return std::string(str); } +#endif + /// Helper for C++20 string literal template arguments template struct FixedString { char data[N]{}; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 173002438ad..f3943376048 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1211,8 +1211,8 @@ def call_lambda(lamb: LambdaExpression) -> Expression: # Developer error if this is called with a lambda that doesn't have a return type assert lamb.return_type is not None, "Lambda must have a return type to be called" expr = lamb.content.strip() - if re.match(r"^return\b", expr) and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression + # A lone `return ;` reduces to the expression; anything longer is called as is. + if re.match(r"^return\b", expr) and expr.endswith(";") and expr.count(";") == 1: expr = RawExpression(expr[6:-1].strip()) # Don't cast if the return type is a class if isinstance(lamb.return_type, MockObjClass): diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 02aedaf167d..53e1f7af6f7 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -319,7 +319,7 @@ cover: logger.log: Cover is closed # Exercise cover.control / cover.template.publish action variants so they # get build coverage in CI (and so memory-impact analysis on PRs that - # touch ControlAction / CoverPublishAction sees real instances). + # touch these actions sees real instances). - platform: template name: "Template Cover Actions" id: template_cover_actions @@ -493,7 +493,7 @@ valve: stop_action: - logger.log: stop_action # Exercise valve.control with various field combinations so the - # ControlAction codegen paths get build coverage. + # valve.control codegen paths get build coverage. - valve.control: id: template_valve stop: true diff --git a/tests/integration/test_cover_control_action.py b/tests/integration/test_cover_control_action.py index 9c7395371bb..ec9a94bf4f0 100644 --- a/tests/integration/test_cover_control_action.py +++ b/tests/integration/test_cover_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for cover ControlAction and CoverPublishAction. +"""Integration test for the cover.control and cover.template.publish actions. Tests that cover.control and cover.template.publish automation actions work correctly with the single stateless apply lambda/function pointer @@ -22,7 +22,7 @@ async def test_cover_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test cover ControlAction/CoverPublishAction with constants and lambdas.""" + """Test cover.control and cover.template.publish with constants and lambdas.""" loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: cover_state_future: asyncio.Future[CoverState] | None = None diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 07ea7533601..c7c5ae998e9 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,12 +8,17 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + ApplyAction, + ApplyCall, + ApplyField, CallbackAutomation, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + maybe_simple_id, + register_apply_action, register_bare_action, register_bare_condition, register_parented_action, @@ -22,8 +27,9 @@ from esphome.automation import ( register_simple_condition, ) import esphome.codegen as cg +import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import ID +from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.util import Registry, RegistryEntry @@ -594,3 +600,208 @@ def test_shared_builders_keep_synchronous_flag( assert actions["my.simple"].synchronous is synchronous assert actions["my.bare"].synchronous is synchronous assert actions["my.parented"].synchronous is synchronous + + +async def _run_apply_action( + registries: tuple[Registry, Registry], + fields: tuple[ApplyField | ApplyCall, ...], + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + call: str | None = None, + platform: str = "esp32", +) -> RegistryEntry: + """Register an apply action and run its builder with the given config.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + actions, _ = registries + register_apply_action("my.apply", None, *fields, call=call) + entry = actions["my.apply"] + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + +def _apply_lambda(mock_cg: MockCodegen) -> str: + return str(mock_cg.new_pvariable.call_args.args[2]) + + +@pytest.mark.asyncio +async def test_register_apply_action_entry( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_action(registries, (), {}, args=[(cg.int32, "x")]) + assert entry.type_id is ApplyAction + assert entry.synchronous is True + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + action_id, template_arg, _ = mock_cg.new_pvariable.call_args.args + assert action_id == ID("obj_1") + assert str(template_arg) == "" + + +@pytest.mark.asyncio +async def test_apply_constants( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """Constants are immediates, strings stay in flash, absent keys emit nothing, order is kept.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ApplyField("on", "set_on", cg.bool_), + ApplyField("song", "play", cg.std_string), + ApplyField("position", "position = {}", cg.float_), + ApplyCall("publish_state()"), + ) + config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5} + await _run_apply_action(registries, fields, config) + text = _apply_lambda(mock_cg) + lines = [ + f"::{PARENT_OBJ}->set_kp(0.0f);", + f"::{PARENT_OBJ}->set_on(false);", + f'::{PARENT_OBJ}->play("a:b");', + f"::{PARENT_OBJ}->position = 0.5f;", + f"::{PARENT_OBJ}->publish_state();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + assert "set_ki" not in text + + +@pytest.mark.asyncio +async def test_apply_lambdas( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A single return reduces to a cast, anything longer is called inline with the trigger args.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ) + config = { + "kp": Lambda("return x * 2;"), + "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), + } + await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) + text = _apply_lambda(mock_cg) + assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + # The parent is global-scope qualified, so an arg named like the id cannot shadow it. + assert f"::{PARENT_OBJ}->set_kp(" in text + assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text + # Outer apply lambda and inner field lambda spell the trigger arg identically. + assert text.count("const std::remove_cvref_t & x") == 2 + assert ( + f"::{PARENT_OBJ}->set_ki([](const std::remove_cvref_t & x) -> float {{" + in text + ) + assert "}(x));" in text + + +@pytest.mark.asyncio +async def test_apply_call_keys( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A multi-key call needs all keys, is skipped with none, and errors on a partial set.""" + fields = ( + ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))), + ) + await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0}) + assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_lambda(mock_cg) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {}) + assert "set_range" not in _apply_lambda(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_action(registries, fields, {"low": 1.0}) + + +@pytest.mark.asyncio +async def test_apply_action_call_shape( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("brightness", "set_brightness", cg.float_),) + await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call") + text = _apply_lambda(mock_cg) + lines = [ + f"auto apply_call = ::{PARENT_OBJ}->make_call();", + "apply_call.set_brightness(0.5f);", + "apply_call.perform();", + ] + positions = [text.index(line) for line in lines] + assert positions == sorted(positions) + + +@pytest.mark.asyncio +async def test_apply_field_nested_key_const_fn_and_type_string( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = ( + ApplyField(("vertical", "direction"), "set_direction", cg.int_), + ApplyField( + "name", + "set_name", + cg.std_string, + const_fn=lambda config, value: f"{cg.safe_exp(value)}, {len(value)}", + ), + ApplyField("value", "value() = {}", "decltype({parent}->value())"), + ) + config = { + "vertical": {"direction": 3}, + "name": "abc", + "value": Lambda("return 42;"), + } + await _run_apply_action(registries, fields, config) + text = _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->set_direction(3);" in text + assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text + assert ( + f"::{PARENT_OBJ}->value() = static_castvalue())>(42);" + in text + ) + + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields[:1], {}) + assert "set_direction" not in _apply_lambda(mock_cg) + + +def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: + with pytest.raises(ValueError, match="2 placeholder"): + ApplyCall("set_range({}, {})", (("low", cg.float_),)) + with pytest.raises(ValueError, match="only bare"): + ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)) + ApplyCall("set_flags({{{}}})", (("flags", cg.int_),)) + with pytest.raises(ValueError, match="each arg is"): + ApplyCall("set_kp({})", (("kp", cg.float_, None, "extra"),)) + schema = cv.Schema({cv.Required(CONF_ID): cv.string, cv.Optional("kp"): cv.float_}) + register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) + register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) + for wrapped in ( + maybe_simple_id(schema), + maybe_simple_id(schema.schema), + cv.All(schema), + cv.maybe_simple_value(schema, key="kp"), + ): + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_action( + "my.bad", wrapped, ApplyField("kd", "set_kd", cg.float_) + ) + nested = cv.Schema({cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_})}) + register_apply_action( + "my.nested", nested, ApplyField(("v", "dir"), "set_dir", cg.int_) + ) + with pytest.raises(ValueError, match="'dri' is not in the schema"): + register_apply_action( + "my.bad2", nested, ApplyField(("v", "dri"), "set_dir", cg.int_) + ) + + +@pytest.mark.asyncio +async def test_apply_string_constant_stays_in_flash_on_esp8266( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + fields = (ApplyField("song", "play", cg.std_string),) + await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266") + assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( + mock_cg + ) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 052513ce979..a618ad50e06 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -251,6 +251,15 @@ class TestCallLambda: assert isinstance(result, cg.StaticCastExpression) assert str(result) == "static_cast(foo + 1)" + def test_call_lambda__return_with_trailing_statements_is_called(self) -> None: + """Only a lone return statement reduces; a longer body is called as is.""" + lamb = cg.LambdaExpression(("return 1;\nfoo();",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result).endswith("}()") + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): """A class return type is not cast, since static_cast doesn't apply to arbitrary class types.""" From 00fa63ea19e17f149af5041f1ad742355a982e29 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:14:57 -0300 Subject: [PATCH 338/759] [climate_ir] Only advertise HEAT_COOL when heat and cool are both supported (#19483) --- esphome/components/climate_ir/__init__.py | 12 +++- esphome/components/climate_ir/climate_ir.cpp | 9 ++- esphome/components/climate_ir/climate_ir.h | 3 + tests/component_tests/climate_ir/__init__.py | 0 .../climate_ir/config/cool_only.yaml | 15 +++++ .../config/cool_only_override_on.yaml | 16 ++++++ .../climate_ir/config/heat_and_cool.yaml | 14 +++++ .../config/heat_and_cool_override_off.yaml | 15 +++++ .../climate_ir/config/heat_only.yaml | 15 +++++ .../climate_ir/config/neither.yaml | 16 ++++++ .../climate_ir/test_supports_heat_cool.py | 53 ++++++++++++++++++ tests/components/climate_ir/__init__.py | 6 ++ .../components/climate_ir/climate_ir_test.cpp | 56 +++++++++++++++++++ ...alidate-cool-only-heat-cool.esp32-idf.yaml | 10 ++++ .../gree/validate-no-cool.esp32-idf.yaml | 9 +++ .../gree/validate-no-heat-cool.esp32-idf.yaml | 9 +++ .../gree/validate-no-heat.esp32-idf.yaml | 9 +++ 17 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/climate_ir/__init__.py create mode 100644 tests/component_tests/climate_ir/config/cool_only.yaml create mode 100644 tests/component_tests/climate_ir/config/cool_only_override_on.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_and_cool.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml create mode 100644 tests/component_tests/climate_ir/config/heat_only.yaml create mode 100644 tests/component_tests/climate_ir/config/neither.yaml create mode 100644 tests/component_tests/climate_ir/test_supports_heat_cool.py create mode 100644 tests/components/climate_ir/__init__.py create mode 100644 tests/components/climate_ir/climate_ir_test.cpp create mode 100644 tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-heat-cool.esp32-idf.yaml create mode 100644 tests/components/gree/validate-no-heat.esp32-idf.yaml diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 0667bd91a26..f5a31b31c4f 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -14,6 +14,8 @@ from esphome.types import ConfigType, SafeExpType _LOGGER = logging.getLogger(__name__) +CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" + DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] @@ -37,6 +39,7 @@ def climate_ir_schema( { cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_HEAT_COOL): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } @@ -61,8 +64,13 @@ def climate_ir_with_receiver_schema( async def register_climate_ir(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_transmittable(var, config) - cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) - cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + supports_cool = config[CONF_SUPPORTS_COOL] + supports_heat = config[CONF_SUPPORTS_HEAT] + cg.add(var.set_supports_cool(supports_cool)) + cg.add(var.set_supports_heat(supports_heat)) + # The header default is true, so only the false case needs a call. + if not config.get(CONF_SUPPORTS_HEAT_COOL, supports_cool and supports_heat): + cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) if sensor_id := config.get(CONF_SENSOR): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index a8edaae6ea8..ca639c7de0f 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,11 +13,13 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); if (this->supports_heat_) traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); + if (this->supports_heat_cool_) + traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); if (this->supports_dry_) traits.add_supported_mode(climate::CLIMATE_MODE_DRY); if (this->supports_fan_only_) @@ -94,9 +96,10 @@ void ClimateIR::dump_config() { " Min. Temperature: %.1f°C\n" " Max. Temperature: %.1f°C\n" " Supports HEAT: %s\n" - " Supports COOL: %s", + " Supports COOL: %s\n" + " Supports HEAT_COOL: %s", this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_)); + YESNO(this->supports_cool_), YESNO(this->supports_heat_cool_)); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 6c49b310303..d65ed5d621b 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -41,6 +41,7 @@ class ClimateIR : public Component, void dump_config() override; void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -60,6 +61,8 @@ class ClimateIR : public Component, bool supports_cool_{true}; bool supports_heat_{true}; + // Default (supports_cool && supports_heat) is resolved during code generation. + bool supports_heat_cool_{true}; bool supports_dry_{false}; bool supports_fan_only_{false}; climate::ClimateFanModeMask fan_modes_{}; diff --git a/tests/component_tests/climate_ir/__init__.py b/tests/component_tests/climate_ir/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/climate_ir/config/cool_only.yaml b/tests/component_tests/climate_ir/config/cool_only.yaml new file mode 100644 index 00000000000..e40efc811a7 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false diff --git a/tests/component_tests/climate_ir/config/cool_only_override_on.yaml b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml new file mode 100644 index 00000000000..82c2d7c8812 --- /dev/null +++ b/tests/component_tests/climate_ir/config/cool_only_override_on.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_heat_cool: true diff --git a/tests/component_tests/climate_ir/config/heat_and_cool.yaml b/tests/component_tests/climate_ir/config/heat_and_cool.yaml new file mode 100644 index 00000000000..d92c2ade39b --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool.yaml @@ -0,0 +1,14 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix diff --git a/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml new file mode 100644 index 00000000000..6a72a5981ee --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_and_cool_override_off.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat_cool: false diff --git a/tests/component_tests/climate_ir/config/heat_only.yaml b/tests/component_tests/climate_ir/config/heat_only.yaml new file mode 100644 index 00000000000..c87a3820b79 --- /dev/null +++ b/tests/component_tests/climate_ir/config/heat_only.yaml @@ -0,0 +1,15 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_cool: false diff --git a/tests/component_tests/climate_ir/config/neither.yaml b/tests/component_tests/climate_ir/config/neither.yaml new file mode 100644 index 00000000000..78830a0a9ba --- /dev/null +++ b/tests/component_tests/climate_ir/config/neither.yaml @@ -0,0 +1,16 @@ +esphome: + name: climateir-heatcool + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +climate: + - platform: coolix + id: test_coolix + name: Coolix + supports_heat: false + supports_cool: false diff --git a/tests/component_tests/climate_ir/test_supports_heat_cool.py b/tests/component_tests/climate_ir/test_supports_heat_cool.py new file mode 100644 index 00000000000..7ff9381cd6b --- /dev/null +++ b/tests/component_tests/climate_ir/test_supports_heat_cool.py @@ -0,0 +1,53 @@ +"""Tests for the supports_heat_cool default resolved in climate_ir code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _emitted_value(main_cpp: str) -> str | None: + """Return the argument of the generated set_supports_heat_cool() call, or None if absent.""" + match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp) + return match.group(1) if match else None + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("heat_and_cool.yaml", None), + ("cool_only.yaml", "false"), + ("heat_only.yaml", "false"), + ("neither.yaml", "false"), + ], +) +def test_default_requires_heat_and_cool( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without the key, HEAT_COOL follows supports_heat and supports_cool.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + ("cool_only_override_on.yaml", None), + ("heat_and_cool_override_off.yaml", "false"), + ], +) +def test_explicit_key_overrides_default( + config: str, + expected: str | None, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it.""" + main_cpp = generate_main(component_config_path(config)) + assert _emitted_value(main_cpp) == expected diff --git a/tests/components/climate_ir/__init__.py b/tests/components/climate_ir/__init__.py new file mode 100644 index 00000000000..ae68ae228af --- /dev/null +++ b/tests/components/climate_ir/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ClimateIR derives from climate::Climate without declaring it as a dependency. + manifest.dependencies = manifest.dependencies + ["climate"] diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp new file mode 100644 index 00000000000..479e1f1b45d --- /dev/null +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -0,0 +1,56 @@ +#include +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::climate_ir::testing { + +class TestClimateIR : public ClimateIR { + public: + TestClimateIR() : ClimateIR(16.0f, 30.0f) {} + + using ClimateIR::traits; + + protected: + void transmit_state() override {} +}; + +// The HEAT_COOL default is covered in tests/component_tests/climate_ir. + +TEST(ClimateIRTest, HeatCoolAdvertisedWhenSupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolNotAdvertisedWhenUnsupported) { + TestClimateIR climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(ClimateIRTest, HeatCoolAdvertisedForCoolOnlyDeviceThatSupportsIt) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { + TestClimateIR climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); +} + +} // namespace esphome::climate_ir::testing diff --git a/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml new file mode 100644 index 00000000000..9b17da7b9bc --- /dev/null +++ b/tests/components/gree/validate-cool-only-heat-cool.esp32-idf.yaml @@ -0,0 +1,10 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false + supports_heat_cool: true diff --git a/tests/components/gree/validate-no-cool.esp32-idf.yaml b/tests/components/gree/validate-no-cool.esp32-idf.yaml new file mode 100644 index 00000000000..3862f302def --- /dev/null +++ b/tests/components/gree/validate-no-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_cool: false diff --git a/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml new file mode 100644 index 00000000000..eea670a696d --- /dev/null +++ b/tests/components/gree/validate-no-heat-cool.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat_cool: false diff --git a/tests/components/gree/validate-no-heat.esp32-idf.yaml b/tests/components/gree/validate-no-heat.esp32-idf.yaml new file mode 100644 index 00000000000..3c042ebe694 --- /dev/null +++ b/tests/components/gree/validate-no-heat.esp32-idf.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +climate: + - platform: gree + name: GREE + transmitter_id: xmitr + model: YAN + supports_heat: false From c609db16b3fa48e410523ffde6b623b1e3fb8846 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:28:32 -0300 Subject: [PATCH 339/759] [gree] Store the model in one byte (#19488) Co-authored-by: J. Nick Koston --- esphome/components/gree/gree.cpp | 22 +++++++++++----------- esphome/components/gree/gree.h | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index a794e7721f5..634a2d33b5e 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -42,23 +42,24 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) { void GreeClimate::transmit_state() { uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; + const Model model = this->model_; remote_state[0] = this->fan_speed_() | this->operation_mode_(); remote_state[1] = this->temperature_(); - if (this->model_ == GREE_YAN) { + if (model == GREE_YAN) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { + if (model == GREE_YX1FF || model == GREE_YAG) { remote_state[2] = 0x60; remote_state[3] = 0x50; remote_state[4] = this->vertical_swing_(); } - if (this->model_ == GREE_YAG) { + if (model == GREE_YAG) { remote_state[5] = 0x40; if (this->vertical_swing_() == GREE_VDIR_SWING || this->horizontal_swing_() == GREE_HDIR_SWING) { @@ -66,11 +67,11 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAC || this->model_ == GREE_YAG) { + if (model == GREE_YAC || model == GREE_YAG) { remote_state[4] |= (this->horizontal_swing_() << 4); } - if (this->model_ == GREE_YAA || this->model_ == GREE_YAC || this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 @@ -82,14 +83,13 @@ void GreeClimate::transmit_state() { } } - if (this->model_ == GREE_YAN || this->model_ == GREE_YAA || this->model_ == GREE_YAC || - this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAN || model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { // Merge the mode bits into remote_state[2] // Clear the mode bits (bits 4-7) and OR in the current mode_bits_ remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_; } - if (this->model_ == GREE_YX1FF) { + if (model == GREE_YX1FF) { if (this->fan_speed_() == GREE_FAN_TURBO) { remote_state[2] |= GREE_FAN_TURBO_BIT; } @@ -100,7 +100,7 @@ void GreeClimate::transmit_state() { } // Calculate the checksum - if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF) { + if (model == GREE_YAN || model == GREE_YX1FF) { remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0); } else { remote_state[7] = @@ -115,7 +115,7 @@ void GreeClimate::transmit_state() { data->set_carrier_frequency(GREE_IR_FREQUENCY); data->mark(GREE_HEADER_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_HEADER_SPACE); } else { data->space(GREE_HEADER_SPACE); @@ -137,7 +137,7 @@ void GreeClimate::transmit_state() { data->space(GREE_ZERO_SPACE); data->mark(GREE_BIT_MARK); - if (this->model_ == GREE_YAC1FB9) { + if (model == GREE_YAC1FB9) { data->space(GREE_YAC1FB9_MESSAGE_SPACE); } else { data->space(GREE_MESSAGE_SPACE); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 2f10be3e6b0..2f879269428 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -77,7 +77,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes -enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; +enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; class GreeClimate final : public climate_ir::ClimateIR { public: From a4bebc308377a2221c914869a34b44ad023bcda5 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 07:29:11 -0300 Subject: [PATCH 340/759] [mitsubishi] Store the fan mode and default directions in one byte each (#19489) Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/mitsubishi.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 7925b7ce44f..657da900e28 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -11,7 +11,7 @@ const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius // Fan mode -enum SetFanMode { +enum SetFanMode : uint8_t { MITSUBISHI_FAN_3L = 0, // 3 levels + auto MITSUBISHI_FAN_4L, // 4 levels + auto MITSUBISHI_FAN_Q4L, // Quiet + 4 levels + auto @@ -19,7 +19,7 @@ enum SetFanMode { }; // Enum to represent horizontal directios -enum HorizontalDirection { +enum HorizontalDirection : uint8_t { HORIZONTAL_DIRECTION_LEFT = 0x10, HORIZONTAL_DIRECTION_MIDDLE_LEFT = 0x20, HORIZONTAL_DIRECTION_MIDDLE = 0x30, @@ -29,7 +29,7 @@ enum HorizontalDirection { }; // Enum to represent vertical directions -enum VerticalDirection { +enum VerticalDirection : uint8_t { VERTICAL_DIRECTION_AUTO = 0x00, VERTICAL_DIRECTION_UP = 0x08, VERTICAL_DIRECTION_MIDDLE_UP = 0x10, From ca46954335834755602b9394e87d1887f2212753 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 08:03:59 -0300 Subject: [PATCH 341/759] [fujitsu_general] Read the mode, fan speed and swing fields with their real width (#18297) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../fujitsu_general/fujitsu_general.cpp | 161 ++++++----- .../fujitsu_general/fujitsu_general.h | 27 ++ tests/components/fujitsu_general/__init__.py | 9 + .../fujitsu_general/fujitsu_general_test.cpp | 254 ++++++++++++++++++ 4 files changed, 366 insertions(+), 85 deletions(-) create mode 100644 tests/components/fujitsu_general/__init__.py create mode 100644 tests/components/fujitsu_general/fujitsu_general_test.cpp diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index aca643defeb..9995a3abbd9 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,12 +2,6 @@ namespace esphome::fujitsu_general { -// bytes' bits are reversed for fujitsu, so nibbles are ordered 1, 0, 3, 2, 5, 4, etc... - -#define SET_NIBBLE(message, nibble, value) \ - ((message)[(nibble) / 2] |= ((value) &0b00001111) << (((nibble) % 2) ? 0 : 4)) -#define GET_NIBBLE(message, nibble) (((message)[(nibble) / 2] >> (((nibble) % 2) ? 0 : 4)) & 0b00001111) - static const char *const TAG = "fujitsu_general.climate"; // Common header @@ -36,32 +30,29 @@ constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; -// Temperature -constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; - // Power on -constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +// Bit 3 is the clean flag, which is also 10 degree heat on the ARRAH2E and ARREW4E remotes. +constexpr uint8_t FUJITSU_GENERAL_MODE_MASK = 0b0111; +constexpr uint8_t FUJITSU_GENERAL_CLEAN_BIT = 0b1000; constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; -// const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; // Swing -constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_SWING_MASK = 0b0011; constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; +constexpr uint8_t FUJITSU_GENERAL_FAN_MASK = 0b0111; constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; @@ -111,68 +102,67 @@ void FujitsuGeneralClimate::transmit_state() { uint8_t temperature_clamped = (uint8_t) roundf(clamp(this->target_temperature, FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX)); uint8_t temperature_offset = temperature_clamped - FUJITSU_GENERAL_TEMP_MIN; - SET_NIBBLE(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); + set_nibble(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); // Set power on if (!this->power_) { - SET_NIBBLE(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); + set_nibble(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); } // Set mode switch (this->mode) { case climate::CLIMATE_MODE_COOL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); break; case climate::CLIMATE_MODE_HEAT: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); break; case climate::CLIMATE_MODE_DRY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); break; case climate::CLIMATE_MODE_FAN_ONLY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); break; case climate::CLIMATE_MODE_HEAT_COOL: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); break; - // TODO: CLIMATE_MODE_10C is missing from esphome } // Set fan switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); break; case climate::CLIMATE_FAN_MEDIUM: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); break; case climate::CLIMATE_FAN_LOW: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); break; case climate::CLIMATE_FAN_QUIET: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); break; case climate::CLIMATE_FAN_AUTO: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); break; } // Set swing switch (this->swing_mode) { case climate::CLIMATE_SWING_VERTICAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); break; case climate::CLIMATE_SWING_HORIZONTAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); break; case climate::CLIMATE_SWING_BOTH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); break; case climate::CLIMATE_SWING_OFF: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); break; } @@ -243,6 +233,52 @@ uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } +// These decoders use if chains rather than switches: on ESP8266 the compiler turns a dense switch +// into a lookup table in .rodata, which lives in RAM there. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode) { + const uint8_t mode = mode_field & FUJITSU_GENERAL_MODE_MASK; + if (mode == FUJITSU_GENERAL_MODE_COOL) + return climate::CLIMATE_MODE_COOL; + if (mode == FUJITSU_GENERAL_MODE_HEAT) + return climate::CLIMATE_MODE_HEAT; + if (mode == FUJITSU_GENERAL_MODE_DRY) + return climate::CLIMATE_MODE_DRY; + if (mode == FUJITSU_GENERAL_MODE_FAN) + return climate::CLIMATE_MODE_FAN_ONLY; + if (mode == FUJITSU_GENERAL_MODE_AUTO) + return climate::CLIMATE_MODE_HEAT_COOL; + // A state frame means the unit is on, so never keep OFF. + ESP_LOGW(TAG, "Received unassigned mode %X, keeping the current mode", mode); + return current_mode == climate::CLIMATE_MODE_OFF ? climate::CLIMATE_MODE_HEAT_COOL : current_mode; +} + +optional decode_fan_mode(uint8_t fan_field, optional current_mode) { + const uint8_t fan = fan_field & FUJITSU_GENERAL_FAN_MASK; + if (fan == FUJITSU_GENERAL_FAN_HIGH) + return climate::CLIMATE_FAN_HIGH; + if (fan == FUJITSU_GENERAL_FAN_MEDIUM) + return climate::CLIMATE_FAN_MEDIUM; + if (fan == FUJITSU_GENERAL_FAN_LOW) + return climate::CLIMATE_FAN_LOW; + if (fan == FUJITSU_GENERAL_FAN_SILENT) + return climate::CLIMATE_FAN_QUIET; + if (fan == FUJITSU_GENERAL_FAN_AUTO) + return climate::CLIMATE_FAN_AUTO; + ESP_LOGW(TAG, "Received unassigned fan speed %X, keeping the current fan mode", fan); + return current_mode; +} + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field) { + const uint8_t swing = swing_field & FUJITSU_GENERAL_SWING_MASK; + if (swing == FUJITSU_GENERAL_SWING_VERTICAL) + return climate::CLIMATE_SWING_VERTICAL; + if (swing == FUJITSU_GENERAL_SWING_HORIZONTAL) + return climate::CLIMATE_SWING_HORIZONTAL; + if (swing == FUJITSU_GENERAL_SWING_BOTH) + return climate::CLIMATE_SWING_BOTH; + return climate::CLIMATE_SWING_OFF; +} + bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { ESP_LOGV(TAG, "Received IR message"); @@ -316,70 +352,25 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { } if (recv_message_type == FUJITSU_GENERAL_MESSAGE_TYPE_STATE) { - const uint8_t recv_tempertature = GET_NIBBLE(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); + const uint8_t recv_tempertature = get_nibble(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); const uint8_t offset_temperature = recv_tempertature + FUJITSU_GENERAL_TEMP_MIN; this->target_temperature = offset_temperature; ESP_LOGV(TAG, "Received temperature %d", offset_temperature); - const uint8_t recv_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); + const uint8_t recv_mode = get_nibble(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); ESP_LOGV(TAG, "Received mode %X", recv_mode); - switch (recv_mode) { - case FUJITSU_GENERAL_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - case FUJITSU_GENERAL_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case FUJITSU_GENERAL_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case FUJITSU_GENERAL_MODE_FAN: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case FUJITSU_GENERAL_MODE_AUTO: - default: - // TODO: CLIMATE_MODE_10C is missing from esphome - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; + if ((recv_mode & FUJITSU_GENERAL_CLEAN_BIT) != 0) { + ESP_LOGW(TAG, "Received a frame with the clean / 10 degree heat bit set, which is not supported"); } + this->mode = decode_mode(recv_mode, this->mode); - const uint8_t recv_fan_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); + const uint8_t recv_fan_mode = get_nibble(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); ESP_LOGV(TAG, "Received fan mode %X", recv_fan_mode); - switch (recv_fan_mode) { - case FUJITSU_GENERAL_FAN_SILENT: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - case FUJITSU_GENERAL_FAN_LOW: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - case FUJITSU_GENERAL_FAN_MEDIUM: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - case FUJITSU_GENERAL_FAN_HIGH: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - case FUJITSU_GENERAL_FAN_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } + this->fan_mode = decode_fan_mode(recv_fan_mode, this->fan_mode); - const uint8_t recv_swing_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); + const uint8_t recv_swing_mode = get_nibble(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); ESP_LOGV(TAG, "Received swing mode %X", recv_swing_mode); - switch (recv_swing_mode) { - case FUJITSU_GENERAL_SWING_VERTICAL: - this->swing_mode = climate::CLIMATE_SWING_VERTICAL; - break; - case FUJITSU_GENERAL_SWING_HORIZONTAL: - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - break; - case FUJITSU_GENERAL_SWING_BOTH: - this->swing_mode = climate::CLIMATE_SWING_BOTH; - break; - case FUJITSU_GENERAL_SWING_NONE: - default: - this->swing_mode = climate::CLIMATE_SWING_OFF; - } + this->swing_mode = decode_swing_mode(recv_swing_mode); this->power_ = true; } diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 93363860d4e..20bda883b2e 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -43,9 +43,36 @@ constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius * heat 30 swing vert 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00101000 00000000 00000000 00000000 00000100 00011101 * heat 30 noswing 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00100000 00000000 00000000 00000000 00000100 00010011 * ``` + * + * The column markers show which bits varied in these captures, not field widths. */ // clang-format on +// Bits are reversed within each byte, so an odd nibble index is the low half of its byte. +constexpr uint8_t get_nibble(const uint8_t *message, uint8_t nibble) { + return (message[nibble / 2] >> ((nibble % 2) ? 0 : 4)) & 0b00001111; +} + +/// Write a nibble into a zero-initialised frame. +constexpr void set_nibble(uint8_t *message, uint8_t nibble, uint8_t value) { + message[nibble / 2] |= (value & 0b00001111) << ((nibble % 2) ? 0 : 4); +} + +// Nibble indices of the state frame fields. +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; + +/// Unassigned values keep the current mode, except that OFF becomes HEAT_COOL. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode); + +/// Unassigned values keep the current fan mode. +optional decode_fan_mode(uint8_t fan_field, optional current_mode); + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field); + class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() diff --git a/tests/components/fujitsu_general/__init__.py b/tests/components/fujitsu_general/__init__.py new file mode 100644 index 00000000000..4ade7998264 --- /dev/null +++ b/tests/components/fujitsu_general/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # This component's AUTO_LOAD = ["climate_ir"] sits on the climate platform manifest, while its + # own __init__.py is empty. The unit test build resolves the bare `fujitsu_general` domain, so + # it never sees that manifest. And climate_ir itself doesn't declare `climate` even though + # ClimateIR derives from climate::Climate. Pull both in so the test can include the header. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/fujitsu_general/fujitsu_general_test.cpp b/tests/components/fujitsu_general/fujitsu_general_test.cpp new file mode 100644 index 00000000000..65b204131a2 --- /dev/null +++ b/tests/components/fujitsu_general/fujitsu_general_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/fujitsu_general/fujitsu_general.h" + +namespace esphome::fujitsu_general::testing { + +// The mode field of a received frame is three bits wide. The fourth bit of the same nibble belongs +// to the clean feature, so it has to be ignored when reading the mode. + +TEST(FujitsuGeneralDecodeModeTest, DecodesTheAssignedModes) { + EXPECT_EQ(decode_mode(0x00, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x01, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x02, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x03, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_EQ(decode_mode(0x04, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, IgnoresTheCleanBit) { + // 0x0B is fan mode with the clean bit set. It used to be read as one value and reported as + // heat/cool, which is the bug this covers. + EXPECT_EQ(decode_mode(0x0B, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + + EXPECT_EQ(decode_mode(0x08, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x09, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0A, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x0C, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, KeepsTheCurrentModeForUnassignedValues) { + // 0x5 to 0x7 fit in the field but the protocol does not use them. + EXPECT_EQ(decode_mode(0x05, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x06, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x07, climate::CLIMATE_MODE_DRY), climate::CLIMATE_MODE_DRY); + + // The same three with the clean bit set. Without the mask these would not reach this branch. + EXPECT_EQ(decode_mode(0x0D, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0E, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x0F, climate::CLIMATE_MODE_FAN_ONLY), climate::CLIMATE_MODE_FAN_ONLY); +} + +TEST(FujitsuGeneralDecodeModeTest, NeverReportsOffForAStateFrame) { + // A state frame describes a running unit, so keeping an off current mode would publish it as off + // and turn the next transmission into a power off command. Automatic is the least specific mode + // available, which is what the field's unassigned values decoded to before they were masked. + for (uint8_t field = 0x05; field <= 0x07; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_mode(field, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(field | 0b1000, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + } +} + +// The fan speed field is three bits wide as well, and used to fold every value it did not +// recognise into the automatic speed. + +TEST(FujitsuGeneralDecodeFanModeTest, DecodesTheAssignedSpeeds) { + EXPECT_EQ(decode_fan_mode(0x00, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x01, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x02, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x03, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x04, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, IgnoresTheFourthBit) { + EXPECT_EQ(decode_fan_mode(0x08, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x09, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0A, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x0B, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0C, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, KeepsTheCurrentFanModeForUnassignedValues) { + EXPECT_EQ(decode_fan_mode(0x05, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x06, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x07, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0D, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0E, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0F, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); +} + +TEST(FujitsuGeneralDecodeFanModeTest, LeavesAnUnsetFanModeUnset) { + EXPECT_FALSE(decode_fan_mode(0x05, {}).has_value()); +} + +// The swing field is only two bits wide. The two bits above it are reserved, and were read as part +// of the value. + +TEST(FujitsuGeneralDecodeSwingModeTest, DecodesTheAssignedValues) { + EXPECT_EQ(decode_swing_mode(0x00), climate::CLIMATE_SWING_OFF); + EXPECT_EQ(decode_swing_mode(0x01), climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(decode_swing_mode(0x02), climate::CLIMATE_SWING_HORIZONTAL); + EXPECT_EQ(decode_swing_mode(0x03), climate::CLIMATE_SWING_BOTH); +} + +TEST(FujitsuGeneralDecodeSwingModeTest, IgnoresTheReservedBits) { + // Without the mask everything from 0x04 up fell through to the default branch and reported swing + // off. All twelve are covered, so the field's whole input space is asserted. + const climate::ClimateSwingMode expected[] = {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}; + for (uint8_t field = 0x04; field <= 0x0F; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_swing_mode(field), expected[field & 0b0011]); + } +} + +// Every state frame annotated in fujitsu_general.h, as the bytes those rows spell out. None of them +// sets the fourth bit of the mode or fan field, or either bit above the swing field, so the masks +// must leave all of them decoding exactly as they did before this change. + +namespace { + +struct CapturedFrame { + const char *label; + uint8_t bytes[16]; + uint8_t temperature; + bool turn_on; + climate::ClimateMode mode; + climate::ClimateFanMode fan_mode; + climate::ClimateSwingMode swing_mode; +}; + +constexpr CapturedFrame CAPTURED_FRAMES[] = { + {"auto auto 18", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x8F}, + 18, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 19", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7F}, + 19, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (temperatures)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"on at 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAB}, + 16, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"down to 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAC}, + 16, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (mode options)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"cool auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCE}, + 30, + true, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"dry auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x02, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCD}, + 30, + true, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"fan (auto) (30)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x03, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCC}, + 30, + true, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCB}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat 30 high", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_SWING_OFF}, + {"heat 30 med", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x02, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_OFF}, + {"heat 30 low", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x03, 0x00, 0x00, 0x00, 0x20, 0xC9}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_OFF}, + {"heat 30 quiet", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, + {"heat 30 swing vert", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x14, 0x00, 0x00, 0x00, 0x20, 0xB8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_VERTICAL}, + {"heat 30 noswing", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, +}; + +} // namespace + +TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) { + for (const auto &frame : CAPTURED_FRAMES) { + SCOPED_TRACE(frame.label); + // Read through the component's own nibble helper and field indices, so this also fails if the + // frame layout the header records ever stops matching what on_receive() reads. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_TEMPERATURE_NIBBLE) + FUJITSU_GENERAL_TEMP_MIN, + frame.temperature); + // The turn on flag is only written by transmit_state(), so this pins the frame layout rather + // than a decode path. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_POWER_ON_NIBBLE) != 0, frame.turn_on); + EXPECT_EQ(decode_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_MODE_NIBBLE), climate::CLIMATE_MODE_OFF), frame.mode); + EXPECT_EQ(decode_fan_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_FAN_NIBBLE), climate::CLIMATE_FAN_ON), + frame.fan_mode); + EXPECT_EQ(decode_swing_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_SWING_NIBBLE)), frame.swing_mode); + } +} + +} // namespace esphome::fujitsu_general::testing From 8c3ebd3414fc3586c621ba77be7146dd44a6755a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:14:04 +0100 Subject: [PATCH 342/759] [docker] Drop the unused PlatformIO library preinstall (#19504) Co-authored-by: esphbot <287758279+esphbot@users.noreply.github.com> --- .devcontainer/Dockerfile | 3 - .github/workflows/ci-docker.yml | 2 - docker/Dockerfile | 3 - docker/test_configs/bk72xx-arduino.yaml | 12 + script/platformio_install_deps.py | 377 ----------- tests/script/test_platformio_install_deps.py | 649 ------------------- 6 files changed, 12 insertions(+), 1034 deletions(-) delete mode 100755 script/platformio_install_deps.py delete mode 100644 tests/script/test_platformio_install_deps.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6f7e8922849..8527cf491b0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,7 +31,4 @@ RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 -COPY script/platformio_install_deps.py platformio.ini ./ -RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools - WORKDIR /workspaces diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index c55b5326ece..5ef51be02cc 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -12,9 +12,7 @@ on: - ".github/workflows/ci-docker.yml" - "requirements*.txt" - "pyproject.toml" - - "platformio.ini" - "esphome/idf_component.yml" - - "script/platformio_install_deps.py" # Core, build pipeline, toolchain, and target-platform changes can change # how a toolchain is set up or built, so re-run the per-toolchain compile # smoke test when they change. diff --git a/docker/Dockerfile b/docker/Dockerfile index e00570c8ff2..4a177ad0069 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,9 +29,6 @@ RUN \ && platformio settings set check_platformio_interval 1000000 \ && mkdir -p /piolibs -COPY script/platformio_install_deps.py platformio.ini / -RUN /platformio_install_deps.py /platformio.ini --libraries - ARG BUILD_VERSION LABEL \ diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml index 138aa9e282c..f05e400c532 100644 --- a/docker/test_configs/bk72xx-arduino.yaml +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -5,3 +5,15 @@ bk72xx: board: generic-bk7231n-qfn32-tuya logger: + +wifi: + ssid: MySSID + password: password1 + ap: + +# mqtt and captive_portal together pull in AsyncTCP and ESPAsyncWebServer; +# a stray ESP32 AsyncTCP copy on the library search path breaks this build +captive_portal: + +mqtt: + broker: 192.168.178.84 diff --git a/script/platformio_install_deps.py b/script/platformio_install_deps.py deleted file mode 100755 index 1c4fb28b300..00000000000 --- a/script/platformio_install_deps.py +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env python3 -# This script is used to preinstall -# all platformio libraries in the global storage - -import argparse -from concurrent.futures import ThreadPoolExecutor -import configparser -from contextlib import suppress -import os -from pathlib import Path -import queue -import subprocess -import threading -import traceback - -# esphome is not installed at this docker layer; pio's fs.rmtree is the -# same chmod-on-readonly shape its own installer uses -try: - from platformio import fs - from platformio.cache import ContentCache - from platformio.package.manager.base import BasePackageManager - from platformio.package.manager.library import LibraryPackageManager - from platformio.package.manager.tool import ToolPackageManager - from platformio.package.meta import PackageCompatibility - - PARALLEL_AVAILABLE = True -except ImportError as err: # pragma: no cover - # A moved pio module must degrade to the serial pass, not kill the - # image build; the tripwire test makes the drift loud in CI - PARALLEL_AVAILABLE = False - IMPORT_ERROR = repr(err) - -# Network-bound downloads release the GIL, so the pool oversubscribes -# the cores. This bypasses pio's 500ms registry throttle and races its -# self-unlinking cache LockFiles; both are cache-only and self-healing. -MAX_WORKERS = 16 - - -class CleanupError(RuntimeError): - """A torn destination could not be removed; the serial pass would - trust it, so the build must fail rather than bake a corrupt image.""" - - -class LockReleaseError(RuntimeError): - """The manager lock could not be released; the serial pass would - block on it, so the build must fail with the cause named.""" - - -def parse_specs(path: str, args: argparse.Namespace) -> tuple[list, list, list]: - """Extract lib/platform/tool specs from every section of a platformio.ini.""" - config = configparser.ConfigParser(inline_comment_prefixes=(";",)) - if not config.read(path): - # ConfigParser silently ignores unreadable files; an empty spec - # list would build an image with no dependencies at all - raise SystemExit(f"Could not read {path}") - libs = [] - tools = [] - platforms = [] - for section in config.sections(): - conf = config[section] - if "lib_deps" in conf and args.libraries: - for lib_dep in conf["lib_deps"].splitlines(): - if not lib_dep: - # Empty line or comment - continue - if lib_dep.startswith("${"): - # Extending from another section - continue - if "@" not in lib_dep: - # No version pinned, this is an internal lib - continue - libs.append(lib_dep) - if "platform" in conf and args.platforms: - platforms.append(conf["platform"]) - if "platform_packages" in conf and args.tools: - for tool in conf["platform_packages"].splitlines(): - if not tool: - # Empty line or comment - continue - if tool.startswith("${"): - # Extending from another section - continue - if tool.find("https://github.com") != -1: - split = tool.find("@") - tool = tool[split + 1 :] - tools.append(tool) - # Exact-string dedupe only: name-level dedupe would change which - # version conflicts the pkg install pass reconciles - return ( - list(dict.fromkeys(libs)), - list(dict.fromkeys(platforms)), - list(dict.fromkeys(tools)), - ) - - -def piopm_matches(package_dir: str, spec) -> list[Path]: - """Dirs whose .piopm metadata names this spec; a positive match beats - guessing the manifest-derived dirname from the registry name.""" - want = (BasePackageManager.ensure_spec(spec).name or "").lower() - matches: list[Path] = [] - if not want: - return matches - try: - entries = list(Path(package_dir).iterdir()) - except FileNotFoundError: - return matches - for d in entries: - if not d.is_dir(): - continue # pio's get_installed skips files and *.pio-link too - try: - meta = fs.load_json(str(d / ".piopm")) - except FileNotFoundError: - continue # no metadata means pio does not trust it either - except (OSError, ValueError): - if d.name.lower() == want: - # A corrupt .piopm under this spec's own name would crash - # pio's whole storage scan; remove it - matches.append(d) - continue - mspec = meta.get("spec") or {} - if (mspec.get("name") or meta.get("name") or "").lower() == want: - matches.append(d) - return matches - - -def remove_dir(spec, dest: Path) -> None: - # fs.rmtree never raises (errors go to a printing onexc handler); - # only the destination's absence proves the cleanup worked - fs.rmtree(str(dest)) - if dest.exists(): - # Failing the build beats baking a corrupt image - raise CleanupError( - f"could not remove the failed pre-install of {spec} at {dest}" - ) - print(f"Removed torn destination {dest}", flush=True) - - -def cleanup_or_die(mgr, spec) -> None: - """Cleanup that did not demonstrably succeed must fail the build.""" - try: - clean_torn(mgr, spec) - except CleanupError: - raise - except Exception as err: # noqa: BLE001 - raise CleanupError(f"cleanup failed for {spec}: {err!r}") from err - - -def clean_torn(mgr, spec) -> None: - """Remove a torn destination so the serial pass cannot trust it.""" - pkg = None - with suppress(Exception): - # get_package memoizes a pre-install snapshot; reset to see the - # torn dir. It also recognizes manifest-only legacy dirs pio's - # storage scan would trust, which the .piopm fallback cannot see. - mgr.memcache_reset() - pkg = mgr.get_package(spec) - if pkg is not None: - remove_dir(spec, Path(pkg.path)) - elif dests := piopm_matches(mgr.package_dir, spec): - # A .piopm naming this spec is the exact shape the serial pass - # trusts; a dir without one is overwritten by pio's own install - for dest in dests: - remove_dir(spec, dest) - else: - print(f"No resolvable destination to clean for {spec}", flush=True) - - -def spec_key(spec) -> str | None: - """The destination identity of a spec: PlatformIO installs by package - name, so two specs sharing a name share a directory. ``None`` means - the name could not be derived; such a spec must stay out of the wave - (a raw-string key would break the one-per-destination guarantee).""" - name = BasePackageManager.ensure_spec(spec).name - return name.lower() if name else None - - -def dependency_specs(manager, specs: list) -> list: - """``(spec, compatibility)`` registry dependencies of installed - packages, from local manifest reads. Name-only dependencies - (platform-bundled libs like SPI) stay with the ``pkg install`` pass; - the compatibility qualifiers mirror pio's install_dependency, so a - qualified dep resolves to the same package the serial pass picks.""" - return [ - (manager.dependency_to_spec(dep), PackageCompatibility.from_dependency(dep)) - for spec in specs - if (pkg := manager.get_package(spec)) is not None - for dep in manager.get_pkg_dependencies(pkg) or [] - if dep.get("owner") or dep.get("version") - ] - - -def parallel_install(manager_cls, specs: list, prior_names: set | None = None) -> None: - """Best-effort parallel top-level install. - - PlatformIO's own installer downloads and unpacks one package at a time - on one core. Dependencies are skipped (two packages sharing one must - not extract into the same directory from two threads) and failures are - only reported: the stock ``pkg install`` pass afterwards installs - whatever is missing and is the authority on the final state. - """ - if not specs: - return - manager = manager_cls(None) - # One spec per destination: two threads must not extract into the - # same directory. Second versions of a name and URL specs (their dir - # comes from the archive manifest) stay with the pkg install pass. - seen_names: set = prior_names if prior_names is not None else set() - # Wave-1 items are strings; dependency waves carry (spec, compatibility) - pairs = [item if isinstance(item, tuple) else (item, None) for item in specs] - unique = {} - for spec, compat in pairs: - # Normalize once: a dependency's URL version surfaces as spec.uri - parsed = BasePackageManager.ensure_spec(spec) - if parsed.uri: - continue - if (key := spec_key(parsed)) is None: - # No name, no destination identity; leave it to the serial pass - print(f"Skipping unresolvable spec {spec!r} in the wave", flush=True) - continue - unique.setdefault(key, (spec, compat)) # first-wins, like pio's walk - pending = [ - (spec, compat) - for spec, compat in unique.values() - if not manager.get_package(spec) - ] - if not pending: - # Nothing to install, but a warm store's dependencies must still - # feed the next wave (a transitive dep may be missing) - _next_wave(manager_cls, manager, unique, seen_names) - return - workers = min(len(pending), MAX_WORKERS) - # One manager per worker (_install mutates instance state); built - # serially because construction rewires the shared manager logger - managers: queue.SimpleQueue = queue.SimpleQueue() - for _ in range(workers): - managers.put(manager_cls(None)) - local = threading.local() - - def install_one(item) -> bool: - spec, compat = item - if (mgr := getattr(local, "mgr", None)) is None: - mgr = local.mgr = managers.get_nowait() - try: - mgr._install( # noqa: SLF001 - spec, skip_dependencies=True, compatibility=compat - ) - return True - except Exception as err: # noqa: BLE001 - print(f"Pre-install of {spec} failed ({err!r})", flush=True) - cleanup_or_die(mgr, spec) - return False - except BaseException: - # A worker SystemExit (main() guards against it) must not skip - # the cleanup and leave a torn dir the serial pass trusts - cleanup_or_die(mgr, spec) - raise - - print(f"Preinstalling {len(pending)} package(s) with {workers} workers", flush=True) - # The serial getter calls create pio's lazy dirs (made without - # exist_ok) before cold-cache workers can race the creation - manager.get_download_dir() - manager.get_tmp_dir() - ContentCache("http") - cwd = Path.cwd() - manager.lock() - try: - with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(install_one, item) for item in pending] - # The with-block joined every future; drain them all so a - # concurrent CleanupError is never dropped - errors = [err for f in futures if (err := f.exception()) is not None] - for err in errors: - # Every failure is on the record; the raised one is a summary - print(f"Wave failure: {err!r}", flush=True) - if errors: - raise next((e for e in errors if isinstance(e, CleanupError)), errors[0]) - results = [f.result() for f in futures] - finally: - try: - manager.unlock() - except Exception as unlock_err: # noqa: BLE001 - # A held flock would hang the serial pass in another process; - # failing loudly beats an unexplained stuck docker build. Any - # in-flight error stays attached as the context. - raise LockReleaseError( - f"could not release the manager lock: {unlock_err!r}" - ) from unlock_err - # Worker postinstall scripts chdir process-wide (pio's fs.cd); - # restore between waves. The serial pass pins its own cwd. - with suppress(OSError): - os.chdir(cwd) - if failures := len(results) - sum(results): - # The stock pass retries CLI specs and re-walks installed - # packages' dependencies, so failed deps retry too - print( - f"Pre-install failed for {failures} of {len(results)} package(s); " - "pkg install retries them serially", - flush=True, - ) - - # Waves skip dependencies (a shared one must not extract from two - # threads); the installed manifests feed the next wave - _next_wave(manager_cls, manager, unique, seen_names) - - -def _next_wave(manager_cls, manager, unique: dict, seen_names: set) -> None: - """Queue the dependency wave for every requested spec, installed or - freshly waved; a warm store can still be missing a transitive dep. - Terminates without a cap: each wave admits only never-seen names.""" - seen_names.update(unique) - # The pre-wave get_package calls memoized an empty storage snapshot - manager.memcache_reset() - next_specs = [ - item - for item in dependency_specs(manager, [spec for spec, _ in unique.values()]) - if spec_key(item[0]) not in seen_names - ] - if next_specs: - parallel_install(manager_cls, next_specs, seen_names) - - -def build_cli_args(libs: list, platforms: list, tools: list) -> list: - return [ - arg - for flag, specs in (("-l", libs), ("-p", platforms), ("-t", tools)) - for spec in specs - for arg in (flag, spec) - ] - - -def main() -> None: - parser = argparse.ArgumentParser(description="") - parser.add_argument("file", help="Path to platformio.ini", nargs=1) - parser.add_argument( - "-l", "--libraries", help="Install libraries", action="store_true" - ) - parser.add_argument( - "-p", "--platforms", help="Install platforms", action="store_true" - ) - parser.add_argument("-t", "--tools", help="Install tools", action="store_true") - args = parser.parse_args() - start_cwd = Path.cwd() - libs, platforms, tools = parse_specs(args.file[0], args) - - # Platforms stay serial: PlatformPackageManager.install runs an - # on_installed hook the private _install path would skip - if PARALLEL_AVAILABLE: - wave_groups = [(ToolPackageManager, tools), (LibraryPackageManager, libs)] - else: # pragma: no cover - wave_groups = [] - print( - f"PlatformIO layout changed ({IMPORT_ERROR}); serial install only", - flush=True, - ) - for manager_cls, specs in wave_groups: - try: - parallel_install(manager_cls, specs) - except (CleanupError, LockReleaseError, KeyboardInterrupt): - # A torn package or a held lock must fail the build - raise - except BaseException: # noqa: BLE001 - # BaseException: a worker postinstall's SystemExit must not - # skip the authoritative serial pass (partial deps, exit 0) - print("Parallel preinstall failed, falling back to serial", flush=True) - traceback.print_exc() - - # Postinstall scripts chdir process-wide (pio's fs.cd captures its - # restore path at construction); pin the authoritative pass's cwd - subprocess.check_call( - ["platformio", "pkg", "install", "-g", *build_cli_args(libs, platforms, tools)], - close_fds=False, - cwd=start_cwd, - ) - - -if __name__ == "__main__": - main() diff --git a/tests/script/test_platformio_install_deps.py b/tests/script/test_platformio_install_deps.py deleted file mode 100644 index 0dce00785bf..00000000000 --- a/tests/script/test_platformio_install_deps.py +++ /dev/null @@ -1,649 +0,0 @@ -"""Tests for script/platformio_install_deps.py.""" - -from argparse import Namespace -import importlib.util -import inspect -from pathlib import Path -import shutil -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from platformio import fs -from platformio.cache import ContentCache -from platformio.exception import InvalidJSONFile -from platformio.package.manager._install import PackageManagerInstallMixin -from platformio.package.manager.base import BasePackageManager -from platformio.package.manager.library import LibraryPackageManager -from platformio.package.manager.tool import ToolPackageManager -from platformio.package.meta import PackageCompatibility, PackageItem, PackageSpec -import pytest -from semantic_version import Version - -_SCRIPT = Path(__file__).parents[2] / "script" / "platformio_install_deps.py" - - -def _load_script(): - spec = importlib.util.spec_from_file_location("platformio_install_deps", _SCRIPT) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # The real ContentCache would create dirs under the user's core dir - module.ContentCache = lambda *_: None - return module - - -def test_spec_key_collapses_destinations() -> None: - """Two specs delivering one package share a directory and one key.""" - mod = _load_script() - assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c" - assert mod.spec_key("esphome/noise-c@1.0") == "noise-c" - assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( - "esp32async/asynctcp @ 3.5.0" - ) - url = "https://github.com/pioarduino/platform-espressif32/releases/download/{v}/platform-espressif32.zip" - assert mod.spec_key(url.format(v="55.03.311")) == mod.spec_key( - url.format(v="54.03.20") - ) - - -def test_parse_specs_and_cli_args(tmp_path: Path) -> None: - """Parsing skips unpinned and interpolated entries; the CLI rebuild - keeps the original flag pairing.""" - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:a]\n" - "platform = fake/platform@1\n" - "lib_deps =\n" - " esphome/noise-c @ 1.0\n" - " ${common.lib_deps}\n" - " internal_lib\n" - "[env:b]\n" - "lib_deps =\n" - " esphome/noise-c @ 1.0\n" - ) - mod = _load_script() - args = Namespace(libraries=True, platforms=True, tools=False) - libs, platforms, tools = mod.parse_specs(str(ini), args) - # exact-string duplicates collapse; distinct version pins survive - assert libs == ["esphome/noise-c @ 1.0"] - assert platforms == ["fake/platform@1"] - assert tools == [] - assert mod.build_cli_args(libs, platforms, tools) == [ - "-l", - "esphome/noise-c @ 1.0", - "-p", - "fake/platform@1", - ] - - -class _FakeManager: - """Scripted manager_cls: records installs, raises on demand.""" - - installed: set = set() - fail: set = set() - calls: list = [] - lock_events: list = [] - base_dir: str = "" # per-test tmp base; set by _reset_fake - - def __init__(self, package_dir) -> None: - assert package_dir is None - - @staticmethod - def _key(spec) -> str: - return spec if isinstance(spec, str) else str(spec) - - def get_package(self, spec): - if self._key(spec) in self.installed: - return SimpleNamespace(path="/tmp/fake-pkg", spec=self._key(spec)) - return None - - def memcache_reset(self) -> None: - type(self).resets = getattr(type(self), "resets", 0) + 1 - - @property - def package_dir(self) -> str: - return str(Path(type(self).base_dir) / "packages") - - def get_download_dir(self) -> str: - return str(Path(type(self).base_dir) / "downloads") - - def get_tmp_dir(self) -> str: - return str(Path(type(self).base_dir) / "tmp") - - def lock(self) -> None: - type(self).lock_events.append("lock") - - def unlock(self) -> None: - type(self).lock_events.append("unlock") - - def _install(self, spec, skip_dependencies, compatibility=None): - assert skip_dependencies is True - if self._key(spec) in self.fail: - raise RuntimeError("boom") - type(self).calls.append(spec) - type(self).compat_calls.append((self._key(spec), compatibility)) - type(self).installed.add(self._key(spec)) # atomic under the GIL - - def get_pkg_dependencies(self, pkg): - return getattr(type(self), "deps", {}).get(pkg.spec) - - dependency_to_spec = staticmethod(BasePackageManager.dependency_to_spec) - - -def _reset_fake(base_dir: str = "", **kwargs) -> type: - # A fresh subclass per test: nothing leaks between tests through the - # class-level scripted state - return type( - "_ScriptedManager", - (_FakeManager,), - { - "base_dir": base_dir, - "installed": kwargs.get("installed", set()), - "fail": kwargs.get("fail", set()), - "calls": [], - "compat_calls": [], - "lock_events": [], - }, - ) - - -def test_parallel_install_empty_specs_is_a_no_op(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - mod.parallel_install(cls, []) - assert cls.calls == [] and cls.lock_events == [] - - -def test_parallel_install_behavior(tmp_path: Path) -> None: - """Duplicates collapse to one install, installed specs are filtered, - URL specs stay out of the wave, and the lock wraps the pool.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/already @ 1.0"}) - mod.parallel_install( - cls, - [ - "esphome/noise-c @ 1.0", - "esphome/noise-c @ 1.0", - "esphome/already @ 1.0", - "https://x/framework.tar.xz", - ], - ) - assert cls.calls == ["esphome/noise-c @ 1.0"] - assert cls.lock_events == ["lock", "unlock"] - - -def test_parallel_install_failure_cleans_torn_destination( - tmp_path: Path, capsys -) -> None: - """A failed install resets the memcache, removes what get_package can - see, and reports; the others still install.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - removed = [] - - torn = str(tmp_path / "packages" / "torn-pkg") # never created; only rmtree'd - - def get_package(self, spec): - if spec == "esphome/bad @ 1.0" and getattr(cls, "resets", 0): - return SimpleNamespace(path=torn, spec=spec) - return _FakeManager.get_package(self, spec) - - cls.get_package = get_package # throwaway subclass; nothing to restore - with patch.object(mod.fs, "rmtree", side_effect=removed.append): - mod.parallel_install(cls, ["esphome/bad @ 1.0", "esphome/good @ 1.0"]) - assert "esphome/good @ 1.0" in cls.calls - assert removed == [torn] - out = capsys.readouterr().out - assert "Pre-install of esphome/bad @ 1.0 failed" in out - assert "Pre-install failed for 1 of 2 package(s)" in out - - -def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None: - """Dependencies of wave-installed packages install in a second wave, - deduped by name; name-only platform libs stay with the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - {"name": "SPI"}, - ], - "esphome/wg @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"]) - assert len(cls.calls) == 3 # the shared dep installs exactly once - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} - # Wave-1 strings carry no compatibility; the dependency wave does - compats = dict(cls.compat_calls) - assert compats["esphome/noise-c @ 1.0"] is None - dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) - assert dep_compat is not None # mirrors pio's install_dependency - - -def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None: - """A dependency pinned to a URL surfaces as spec.uri; it must stay out - of the wave like string URL specs do.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"name": "vendored", "version": "https://github.com/x/y.git"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) - assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} - - -def test_failed_cleanup_fails_the_build(tmp_path: Path) -> None: - """A torn destination still on disk after rmtree must fail the build: - fs.rmtree never raises (its onexc handler prints), so only the - destination's absence proves the cleanup worked.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls.get_package = get_package # throwaway subclass; nothing to restore - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] # still released - - -def test_unverifiable_torn_destination_fails_the_build(tmp_path: Path) -> None: - """When the scan fails, the spec's own .piopm decides: an unremovable - leftover fails the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - def bad_reset(self): - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # onexc swallowed - pytest.raises(mod.CleanupError, match="could not remove"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_unverifiable_scan_without_leftover_degrades(tmp_path: Path, capsys) -> None: - """A failing scan with no destination on disk is never a build - failure blaming this spec.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - resets = {"n": 0} - - def bad_reset(self): - # Fail clean_torn's reset; the coordinator's later reset works - resets["n"] += 1 - if resets["n"] <= 1: - raise OSError("scan broken") - - cls.memcache_reset = bad_reset - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unresolvable_torn_destination_is_printed(tmp_path: Path, capsys) -> None: - """A failed install with no resolvable package prints, so an invisible - torn directory is at least traceable.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert "No resolvable destination to clean" in capsys.readouterr().out - - -def test_unparsable_torn_destination_is_removed(tmp_path: Path, capsys) -> None: - """A torn dir get_package cannot resolve but whose .piopm names the - spec is removed instead of surviving into the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - dest = Path(cls.base_dir) / "packages" / "bad" - dest.mkdir(parents=True) - (dest / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not dest.exists() - assert "Removed torn destination" in capsys.readouterr().out - - -def test_parse_specs_tools_branch(tmp_path: Path) -> None: - """platform_packages parsing keeps owner'd tools and rewrites github - URL pins to bare URLs the wave then skips via parsed.uri.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text( - "[env:t]\n" - "platform_packages =\n" - " ${common.platform_packages}\n" - " platformio/tool-scons@~4.40801.0\n" - " framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip\n" - ) - args = Namespace(libraries=False, platforms=False, tools=True) - libs, platforms, tools = mod.parse_specs(str(ini), args) - assert libs == [] and platforms == [] - assert tools == [ - "platformio/tool-scons@~4.40801.0", - "https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip", - ] - assert mod.build_cli_args([], [], tools)[:2] == ["-t", tools[0]] - - -def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None: - """Already-installed top-level packages still feed the dependency - wave; a warm store can be missing a transitive dep.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"}) - cls.deps = { - "esphome/noise-c @ 1.0": [ - {"owner": "esphome", "name": "libsodium", "version": "^1.0"}, - ], - } - mod.parallel_install(cls, ["esphome/noise-c @ 1.0"]) - assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] - - -def test_worker_system_exit_still_cleans(tmp_path: Path, capsys) -> None: - """A worker SystemExit runs the torn cleanup before propagating; the - serial pass must never trust its leftovers.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - torn = tmp_path / "packages" / "torn-pkg" - torn.mkdir(parents=True) - - def exiting_install(self, spec, skip_dependencies, compatibility=None): - raise SystemExit(0) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - cls._install = exiting_install - cls.get_package = get_package - - def real_rmtree(path): - Path(path).rmdir() - - with ( - patch.object(mod.fs, "rmtree", real_rmtree), - pytest.raises(SystemExit), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unlock_failure_is_fatal(tmp_path: Path) -> None: - """A failed unlock must fail the build: the serial pass in another - process would block on the held flock.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - - def bad_unlock(self): - raise OSError("flock broke") - - cls.unlock = bad_unlock - with pytest.raises(mod.LockReleaseError, match="manager lock"): - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - - -def test_unlock_failure_keeps_inflight_error_as_context(tmp_path: Path) -> None: - """An in-flight CleanupError stays attached when the unlock fault - takes over the raise.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - - def get_package(self, spec): - if getattr(cls, "resets", 0): - return SimpleNamespace(path=str(torn), spec=spec) - return None - - def bad_unlock(self): - raise OSError("flock broke") - - cls.get_package = get_package - cls.unlock = bad_unlock - with ( - patch.object(mod.fs, "rmtree", lambda path: None), # leaves torn - pytest.raises(mod.LockReleaseError) as err, - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert isinstance(err.value.__cause__.__context__, mod.CleanupError) - - -def test_chdir_failure_does_not_fail_the_wave(tmp_path: Path, monkeypatch) -> None: - """A lost cwd is suppressed: further waves may misbehave and fall to - the serial pass, whose cwd is pinned.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - monkeypatch.setattr(mod.os, "chdir", MagicMock(side_effect=OSError("gone"))) - mod.parallel_install(cls, ["esphome/good @ 1.0"]) - assert cls.calls == ["esphome/good @ 1.0"] - - -def test_piopm_match_removes_manifest_named_torn_dir(tmp_path: Path, capsys) -> None: - """A torn dir named by its manifest (not the registry spec) is found - through its .piopm and removed.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "ManifestName" - torn.mkdir(parents=True) - (torn / ".piopm").write_text('{"spec": {"owner": "esphome", "name": "bad"}}') - innocent = tmp_path / "packages" / "innocent" - innocent.mkdir() - (innocent / ".piopm").write_text('{"spec": {"owner": "o", "name": "other"}}') - with patch.object(mod.fs, "rmtree", shutil.rmtree): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - assert innocent.exists() # another package's valid metadata survives - assert "Removed torn destination" in capsys.readouterr().out - - -def test_unscannable_package_dir_fails_the_build(tmp_path: Path) -> None: - """A storage dir the cleanup cannot scan is not proof of cleanliness.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - real_iterdir = Path.iterdir - - def broken_iterdir(self): - if self.name == "packages": - raise PermissionError("denied") - return real_iterdir(self) - - with ( - patch.object(Path, "iterdir", broken_iterdir), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_stray_file_in_package_dir_is_ignored(tmp_path: Path) -> None: - """A plain file (or a pio-link) beside the packages is skipped by - pio's own scan and must never hard-fail the build.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - (tmp_path / "packages").mkdir(parents=True) - (tmp_path / "packages" / "stray.pio-link").write_text("x") - (tmp_path / "packages" / "no-metadata").mkdir() # pio overwrites these - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert (tmp_path / "packages" / "stray.pio-link").exists() - assert (tmp_path / "packages" / "no-metadata").exists() - - -def test_unreadable_piopm_dir_is_removed(tmp_path: Path) -> None: - """A persistently corrupt .piopm under this spec's own name would - crash pio's storage scan; the dir is removed rather than left to - break the serial pass.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - torn = tmp_path / "packages" / "bad" - torn.mkdir(parents=True) - (torn / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert not torn.exists() - - -def test_unreadable_piopm_under_other_name_survives(tmp_path: Path) -> None: - """A corrupt .piopm in another package's dir may be a worker mid-copy; - a failing spec must not remove a directory it does not own.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - other = tmp_path / "packages" / "innocent" - other.mkdir(parents=True) - (other / ".piopm").write_text("{not json") - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - assert other.exists() - - -def test_unexpected_cleanup_class_becomes_cleanup_error(tmp_path: Path) -> None: - """Cleanup failures of any class fail the build; nothing may be - downgraded to the serial fallback over a torn directory.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path), fail={"esphome/bad @ 1.0"}) - - with ( - patch.object( - mod, "piopm_matches", MagicMock(side_effect=ValueError("bad spec")) - ), - pytest.raises(mod.CleanupError, match="cleanup failed"), - ): - mod.parallel_install(cls, ["esphome/bad @ 1.0"]) - - -def test_main_cleanup_error_fails_before_generic_fallback(tmp_path: Path) -> None: - """A CleanupError must escape main's serial fallback: the clause order - decides whether a stuck torn package fails the image build.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object( - mod, "parallel_install", side_effect=mod.CleanupError("stuck torn pkg") - ), - patch.object(mod.subprocess, "check_call"), - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - pytest.raises(mod.CleanupError), - ): - mod.main() - - -def test_main_generic_failure_still_runs_serial_pass(tmp_path: Path) -> None: - """A non-CleanupError wave failure prints, dumps the traceback, and - still reaches the authoritative serial pass with the pinned cwd.""" - mod = _load_script() - ini = tmp_path / "platformio.ini" - ini.write_text("[env:t]\nlib_deps =\n esphome/x @ 1.0\n") - with ( - patch.object(mod, "parallel_install", side_effect=RuntimeError("boom")), - patch.object(mod.subprocess, "check_call") as mock_call, - patch.object(sys, "argv", ["platformio_install_deps.py", str(ini), "-l"]), - ): - mod.main() - mock_call.assert_called_once() - args, kwargs = mock_call.call_args - assert args[0][:4] == ["platformio", "pkg", "install", "-g"] - assert "esphome/x @ 1.0" in args[0] - assert kwargs["cwd"] == Path.cwd() - - -def test_content_cache_creates_its_dir(tmp_path: Path, monkeypatch) -> None: - """The cold-cache hardening relies on ContentCache.__init__ creating - the namespace dir; pin the side effect, not mere callability.""" - monkeypatch.setenv("PLATFORMIO_CACHE_DIR", str(tmp_path / "cache")) - ContentCache("http") - assert (tmp_path / "cache" / "http").is_dir() - - -def test_piopm_matches_without_name_matches_nothing(tmp_path: Path) -> None: - """A spec with no derivable name can never match a directory.""" - mod = _load_script() - assert mod.piopm_matches(str(tmp_path), "") == [] - - -def test_unresolvable_spec_stays_out_of_the_wave(tmp_path: Path, capsys) -> None: - """A spec with no derivable name is left to the serial pass; a raw - string key would break the one-per-destination dedupe.""" - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - nameless = PackageSpec(requirements="^1.0") - mod.parallel_install(cls, [nameless]) - assert cls.calls == [] - assert "Skipping unresolvable spec" in capsys.readouterr().out - - -def test_parallel_install_unlocks_when_pool_fails(tmp_path: Path) -> None: - mod = _load_script() - cls = _reset_fake(str(tmp_path)) - with ( - patch.object(mod, "ThreadPoolExecutor", side_effect=RuntimeError("no")), - pytest.raises(RuntimeError), - ): - mod.parallel_install(cls, ["esphome/a @ 1.0"]) - assert cls.lock_events == ["lock", "unlock"] - - -def test_parse_specs_unreadable_ini_fails_loudly(tmp_path: Path) -> None: - """A bad path must not silently build an image with no dependencies.""" - mod = _load_script() - args = Namespace(libraries=True, platforms=False, tools=False) - with pytest.raises(SystemExit): - mod.parse_specs(str(tmp_path / "missing.ini"), args) - - -def test_platformio_surface_for_install_deps_script() -> None: - """A PlatformIO bump that changes these members must fail here, not - silently turn the docker image's parallel preinstall into a no-op.""" - # The script calls these positionally; pin the positions, not just - # membership, so a parameter reorder trips the wire too - params = inspect.signature(PackageManagerInstallMixin._install).parameters - assert list(params)[1] == "spec" - assert "skip_dependencies" in params - assert "compatibility" in params - for cls in (ToolPackageManager, LibraryPackageManager): - assert list(inspect.signature(cls.__init__).parameters)[1] == "package_dir" - for name in ( - "lock", - "unlock", - "get_package", - "memcache_reset", - "get_pkg_dependencies", - "dependency_to_spec", - "get_download_dir", - "get_tmp_dir", - ): - assert callable(getattr(BasePackageManager, name)) - # Losing any of these turns the wave into main()'s silent serial - # fallback: ensure_spec runs in the coordinator, the spec attributes - # feed the dedupe, cleanup, and dependency filters - assert callable(BasePackageManager.ensure_spec) - spec = PackageSpec("owner/name @ ^1.0") - assert spec.name == "name" - assert spec.owner == "owner" - assert spec.uri is None - assert spec.external is False - assert Version("1.5.0") in spec.requirements - # The failure-cleanup path degrades to a single line if these vanish - assert callable(fs.rmtree) - assert callable(fs.load_json) - # piopm_matches only tolerates a corrupt .piopm through this base; - # losing it would flip a wave failure from degrade to build failure - assert issubclass(InvalidJSONFile, ValueError) - assert PackageItem("pkg-dir").path == "pkg-dir" - assert callable(PackageCompatibility.from_dependency) From 01fc7511aecf6c34ddf9694353cb9f822292be23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:15:21 +0100 Subject: [PATCH 343/759] [core] Call a lambda whose only statement returns a braced value (#19503) --- esphome/cpp_generator.py | 8 +++++++- tests/unit_tests/test_cpp_generator.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index f3943376048..a681583053e 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1212,7 +1212,13 @@ def call_lambda(lamb: LambdaExpression) -> Expression: assert lamb.return_type is not None, "Lambda must have a return type to be called" expr = lamb.content.strip() # A lone `return ;` reduces to the expression; anything longer is called as is. - if re.match(r"^return\b", expr) and expr.endswith(";") and expr.count(";") == 1: + # A braced return such as `return {};` needs the lambda's return type, so it is called. + if ( + re.match(r"^return\b", expr) + and expr.endswith(";") + and expr.count(";") == 1 + and not expr[6:].lstrip().startswith("{") + ): expr = RawExpression(expr[6:-1].strip()) # Don't cast if the return type is a class if isinstance(lamb.return_type, MockObjClass): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index a618ad50e06..fa6255b8c9e 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -260,6 +260,15 @@ class TestCallLambda: assert isinstance(result, cg.CallExpression) assert str(result).endswith("}()") + def test_call_lambda__braced_return_is_called(self) -> None: + """A braced return needs the lambda's return type, so it is not reduced.""" + lamb = cg.LambdaExpression(("return {};",), (), "", ct.int_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert "static_cast" not in str(result) + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): """A class return type is not cast, since static_cast doesn't apply to arbitrary class types.""" From 56f7280d551ddf6636485a2794ebd67ca96d0067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:16:47 +0100 Subject: [PATCH 344/759] [switch] Use register_apply_action for the switch actions (#19498) --- esphome/components/switch/__init__.py | 40 ++++--------- esphome/components/switch/automation.h | 58 ------------------- .../components/template/switch/__init__.py | 11 +--- tests/components/template/common-base.yaml | 6 ++ 4 files changed, 20 insertions(+), 95 deletions(-) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 18b95113cc0..e9d49b0ed01 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -54,12 +54,6 @@ RESTORE_MODES = { } -ControlAction = switch_ns.class_("ControlAction", automation.Action) -ToggleAction = switch_ns.class_("ToggleAction", automation.Action) -TurnOffAction = switch_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) -SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) - SwitchCondition = switch_ns.class_("SwitchCondition", Condition) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -193,29 +187,19 @@ SWITCH_CONTROL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "switch.control", ControlAction, SWITCH_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "switch.control", + SWITCH_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "control", cg.bool_), ) -async def switch_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "switch.toggle", ToggleAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "switch.turn_off", TurnOffAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "switch.turn_on", TurnOnAction, SWITCH_ACTION_SCHEMA, synchronous=True -) -async def switch_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("switch.toggle", "toggle()"), + ("switch.turn_off", "turn_off()"), + ("switch.turn_on", "turn_on()"), +): + automation.register_apply_action( + _name, SWITCH_ACTION_SCHEMA, automation.ApplyCall(_call) + ) @automation.register_condition("switch.is_on", SwitchCondition, SWITCH_ACTION_SCHEMA) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index 158fb08baff..ce81f53475d 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,53 +6,6 @@ namespace esphome::switch_ { -template class TurnOnAction final : public Action { - public: - explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_on(); } - - protected: - Switch *switch_; -}; - -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->turn_off(); } - - protected: - Switch *switch_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} - - void play(const Ts &...x) override { this->switch_->toggle(); } - - protected: - Switch *switch_; -}; - -template class ControlAction final : public Action { - public: - explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} - - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.optional_value(x...); - if (state.has_value()) { - this->switch_->control(*state); - } - } - - protected: - Switch *switch_; -}; - template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} @@ -92,15 +45,4 @@ class SwitchTurnOffTrigger final : public Trigger<> { } }; -template class SwitchPublishAction final : public Action { - public: - SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { this->switch_->publish_state(this->state_.value(x...)); } - - protected: - Switch *switch_; -}; - } // namespace esphome::switch_ diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 0b246869363..f8e509faf36 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -79,20 +79,13 @@ async def to_code(config): cg.add(var.set_assumed_state(True)) -@automation.register_action( +automation.register_apply_action( "switch.template.publish", - switch.SwitchPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(switch.Switch), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def switch_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 53e1f7af6f7..a87e2e980ae 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -257,8 +257,14 @@ switch: return false; turn_on_action: - logger.log: "turn_on_action" + - switch.template.publish: + id: test_switch + state: true turn_off_action: - logger.log: "turn_off_action" + - switch.template.publish: + id: test_switch + state: !lambda return false; button: - platform: template From e7ff903284a09af57f0fccafac48735a4a77f7ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:17:06 +0100 Subject: [PATCH 345/759] [valve] Use register_apply_action for valve.control (#19474) --- esphome/components/valve/__init__.py | 61 +++++---------------------- esphome/components/valve/automation.h | 28 ------------ 2 files changed, 11 insertions(+), 78 deletions(-) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7d98af402dc..692759f3e42 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -21,14 +21,14 @@ from esphome.const import ( DEVICE_CLASS_GAS, DEVICE_CLASS_WATER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -43,7 +43,6 @@ DEVICE_CLASSES = [ valve_ns = cg.esphome_ns.namespace("valve") Valve = valve_ns.class_("Valve", cg.EntityBase) -ValveCall = valve_ns.class_("ValveCall") VALVE_OPEN = valve_ns.VALVE_OPEN VALVE_CLOSED = valve_ns.VALVE_CLOSED @@ -67,7 +66,6 @@ OpenAction = valve_ns.class_("OpenAction", automation.Action) CloseAction = valve_ns.class_("CloseAction", automation.Action) StopAction = valve_ns.class_("StopAction", automation.Action) ToggleAction = valve_ns.class_("ToggleAction", automation.Action) -ControlAction = valve_ns.class_("ControlAction", automation.Action) ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) ValveIsOpenCondition = valve_ns.class_("ValveIsOpenCondition", Condition) ValveIsClosedCondition = valve_ns.class_("ValveIsClosedCondition", Condition) @@ -226,53 +224,16 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA, synchronous=True +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both dispatch to set_position. +automation.register_apply_action( + "valve.control", + VALVE_CONTROL_ACTION_SCHEMA, + automation.ApplyField(CONF_STOP, "set_stop", cg.bool_), + automation.ApplyField(CONF_STATE, "set_position", cg.float_), + automation.ApplyField(CONF_POSITION, "set_position", cg.float_), + call="make_call", ) -async def valve_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most - # one is present and both dispatch to set_position. - FIELDS = ( - (CONF_STOP, "set_stop", cg.bool_), - (CONF_STATE, "set_position", cg.float_), - (CONF_POSITION, "set_position", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ValveCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 63d03a889b3..9fdb7665b3c 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -46,34 +46,6 @@ template class ToggleAction final : public Action { Valve *valve_; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `position: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); - ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->valve_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Valve *valve_; - ApplyFn apply_; -}; - template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} From 418ae738702d44be75e9d070c748d673e2b0bc24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:18:55 +0100 Subject: [PATCH 346/759] [template] Use register_apply_action for climate.template.publish (#19495) --- .../components/template/climate/__init__.py | 76 ++++++------------- .../components/template/climate/automation.h | 57 -------------- .../template/climate/template_climate.h | 3 +- tests/components/template/common-base.yaml | 4 + 4 files changed, 30 insertions(+), 110 deletions(-) delete mode 100644 esphome/components/template/climate/automation.h diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index c39ea8f80e9..11ffaf282c3 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -28,8 +28,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_HIGH, CONF_TARGET_TEMPERATURE_LOW, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import template_ns @@ -53,11 +51,6 @@ CONF_SET_PRESET_ACTION = "set_preset_action" CONF_SET_CUSTOM_PRESET_ACTION = "set_custom_preset_action" TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Component) -TemplateClimatePublishAction = template_ns.class_( - "TemplateClimatePublishAction", - automation.Action, - cg.Parented.template(TemplateClimate), -) TemplateClimateRestoreMode = template_ns.enum( "TemplateClimateRestoreMode", is_class=True @@ -416,50 +409,31 @@ CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "climate.template.publish", - TemplateClimatePublishAction, CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + CONF_CURRENT_TEMPERATURE, "current_temperature = {}", cg.float_ + ), + automation.ApplyField(CONF_CURRENT_HUMIDITY, "current_humidity = {}", cg.float_), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_MODE, "set_mode", climate.ClimateMode), + automation.ApplyField(CONF_ACTION, "action = {}", climate.ClimateAction), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", climate.ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, "set_custom_fan_mode(StringRef({}))", cg.std_string + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", climate.ClimateSwingMode), + automation.ApplyField(CONF_PRESET, "set_preset", climate.ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, "set_custom_preset(StringRef({}))", cg.std_string + ), + automation.ApplyCall("publish_state()"), ) -async def climate_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - if (v := config.get(CONF_CURRENT_TEMPERATURE)) is not None: - cg.add(var.set_current_temperature(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_CURRENT_HUMIDITY)) is not None: - cg.add(var.set_current_humidity(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE)) is not None: - cg.add(var.set_target_temperature(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - cg.add(var.set_target_temperature_low(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - cg.add( - var.set_target_temperature_high(await cg.templatable(v, args, cg.float_)) - ) - if (v := config.get(CONF_TARGET_HUMIDITY)) is not None: - cg.add(var.set_target_humidity(await cg.templatable(v, args, cg.float_))) - if (v := config.get(CONF_MODE)) is not None: - cg.add(var.set_mode(await cg.templatable(v, args, climate.ClimateMode))) - if (v := config.get(CONF_ACTION)) is not None: - cg.add(var.set_action(await cg.templatable(v, args, climate.ClimateAction))) - if (v := config.get(CONF_FAN_MODE)) is not None: - cg.add(var.set_fan_mode(await cg.templatable(v, args, climate.ClimateFanMode))) - if (v := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - cg.add(var.set_custom_fan_mode(await cg.templatable(v, args, cg.std_string))) - if (v := config.get(CONF_SWING_MODE)) is not None: - cg.add( - var.set_swing_mode(await cg.templatable(v, args, climate.ClimateSwingMode)) - ) - if (v := config.get(CONF_PRESET)) is not None: - cg.add(var.set_preset(await cg.templatable(v, args, climate.ClimatePreset))) - if (v := config.get(CONF_CUSTOM_PRESET)) is not None: - cg.add(var.set_custom_preset(await cg.templatable(v, args, cg.std_string))) - - return var diff --git a/esphome/components/template/climate/automation.h b/esphome/components/template/climate/automation.h deleted file mode 100644 index 49a79ace2f1..00000000000 --- a/esphome/components/template/climate/automation.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include "template_climate.h" -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template -class TemplateClimatePublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, current_temperature) - TEMPLATABLE_VALUE(float, current_humidity) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(climate::ClimateMode, mode) - TEMPLATABLE_VALUE(climate::ClimateAction, action) - TEMPLATABLE_VALUE(climate::ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(climate::ClimateSwingMode, swing_mode) - TEMPLATABLE_VALUE(climate::ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - - void play(const Ts &...x) override { - if (this->current_temperature_.has_value()) - this->parent_->current_temperature = this->current_temperature_.value(x...); - if (this->current_humidity_.has_value()) - this->parent_->current_humidity = this->current_humidity_.value(x...); - if (this->target_temperature_.has_value()) - this->parent_->set_target_temperature(this->target_temperature_.value(x...)); - if (this->target_temperature_low_.has_value()) - this->parent_->set_target_temperature_low(this->target_temperature_low_.value(x...)); - if (this->target_temperature_high_.has_value()) - this->parent_->set_target_temperature_high(this->target_temperature_high_.value(x...)); - if (this->target_humidity_.has_value()) - this->parent_->set_target_humidity(this->target_humidity_.value(x...)); - if (this->mode_.has_value()) - this->parent_->set_mode(this->mode_.value(x...)); - if (this->action_.has_value()) - this->parent_->action = this->action_.value(x...); - if (this->fan_mode_.has_value()) - this->parent_->set_fan_mode(this->fan_mode_.value(x...)); - if (this->custom_fan_mode_.has_value()) - this->parent_->set_custom_fan_mode(StringRef(this->custom_fan_mode_.value(x...))); - if (this->swing_mode_.has_value()) - this->parent_->set_swing_mode(this->swing_mode_.value(x...)); - if (this->preset_.has_value()) - this->parent_->set_preset(this->preset_.value(x...)); - if (this->custom_preset_.has_value()) - this->parent_->set_custom_preset(StringRef(this->custom_preset_.value(x...))); - - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h index 5448488c345..4cdf213315c 100644 --- a/esphome/components/template/climate/template_climate.h +++ b/esphome/components/template/climate/template_climate.h @@ -50,8 +50,7 @@ class TemplateClimate final : public climate::Climate, public Component { Trigger *get_set_preset_trigger() { return &this->set_preset_trigger_; } Trigger *get_set_custom_preset_trigger() { return &this->set_custom_preset_trigger_; } - // Used by TemplateClimatePublishAction, which is not a Climate subclass and so cannot reach the - // protected setters, and by codegen to apply `initial_state:` before setup() runs. + // Public so the climate.template.publish action and the initial_state codegen can set them. void set_target_temperature(float value) { this->target_temperature = value; } void set_target_temperature_low(float value) { this->target_temperature_low = value; } void set_target_temperature_high(float value) { this->target_temperature_high = value; } diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index a87e2e980ae..8c07b3a2b77 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -45,6 +45,10 @@ esphome: id: template_climate_custom_modes custom_fan_mode: "turbo" custom_preset: "eco_plus" + - climate.template.publish: + id: template_climate_custom_modes + custom_fan_mode: !lambda return std::string("turbo"); + custom_preset: !lambda return std::string("eco_plus"); # Test C++ API: set_template() with stateless lambda (no captures) # NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break. From 14baf03aadf6044545d4a8fd3bbc5e5ec2ed1335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:07 +0100 Subject: [PATCH 347/759] [cover] Use register_apply_action for the open, close, stop and toggle actions (#19500) --- esphome/components/cover/__init__.py | 63 ++++------------------ esphome/components/cover/automation.h | 40 -------------- tests/components/template/common-base.yaml | 4 ++ 3 files changed, 13 insertions(+), 94 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 05a9afad615..f7e029ad9e3 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -87,10 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -OpenAction = cover_ns.class_("OpenAction", automation.Action) -CloseAction = cover_ns.class_("CloseAction", automation.Action) -StopAction = cover_ns.class_("StopAction", automation.Action) -ToggleAction = cover_ns.class_("ToggleAction", automation.Action) CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( @@ -251,56 +247,15 @@ COVER_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_open_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_close_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True -) -async def cover_toggle_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _command in ( + ("cover.open", "set_command_open()"), + ("cover.close", "set_command_close()"), + ("cover.stop", "set_command_stop()"), + ("cover.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, COVER_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) COVER_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 20f56e17538..9af4a1eee50 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,46 +6,6 @@ namespace esphome::cover { -template class OpenAction final : public Action { - public: - explicit OpenAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_open().perform(); } - - protected: - Cover *cover_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_close().perform(); } - - protected: - Cover *cover_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_stop().perform(); } - - protected: - Cover *cover_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Cover *cover) : cover_(cover) {} - - void play(const Ts &...x) override { this->cover_->make_call().set_command_toggle().perform(); } - - protected: - Cover *cover_; -}; - template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 8c07b3a2b77..fd868b97098 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -377,6 +377,10 @@ cover: - cover.control: id: template_cover_actions stop: true + - cover.open: template_cover_actions + - cover.close: template_cover_actions + - cover.stop: template_cover_actions + - cover.toggle: template_cover_actions # CONF_STATE alias for position - cover.control: id: template_cover_actions From 547624edf9eddfb47d2bb5a7553f82856201457c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:24 +0100 Subject: [PATCH 348/759] [at581x] Use register_apply_action for the settings and reset actions (#19496) --- esphome/components/at581x/__init__.py | 105 +++++++------------------ esphome/components/at581x/at581x.h | 4 + esphome/components/at581x/automation.h | 69 ---------------- 3 files changed, 32 insertions(+), 146 deletions(-) delete mode 100644 esphome/components/at581x/automation.h diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 193e62f6157..a29e166745e 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,8 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] @@ -80,30 +78,21 @@ async def to_code(config: ConfigType) -> None: # Actions -AT581XResetAction = at581x_ns.class_("AT581XResetAction", automation.Action) -AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Action) -@automation.register_action( +automation.register_apply_action( "at581x.reset", - AT581XResetAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(AT581XComponent), - } - ), - synchronous=True, + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(AT581XComponent)}), + automation.ApplyCall("reset_hardware_frontend()"), ) -async def at581x_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var + +def _megahertz(value: float) -> int: + return int(value / 1000000) + + +def _microamps(value: float) -> int: + return int(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( @@ -111,7 +100,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( cv.Required(CONF_ID): cv.use_id(AT581XComponent), cv.Optional(CONF_HW_FRONTEND_RESET): cv.templatable(cv.boolean), cv.Optional(CONF_FREQUENCY, default="5800MHz"): cv.templatable( - cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ)) + cv.All(cv.frequency, cv.one_of(*RADAR_ALLOWED_FREQ), _megahertz) ), cv.Optional(CONF_SENSING_DISTANCE, default=823): cv.templatable( cv.int_range(min=0, max=1023) @@ -123,7 +112,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) ), cv.Optional(CONF_POWER_CONSUMPTION, default="70uA"): cv.templatable( - cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION)) + cv.All(cv.current, cv.one_of(*RADAR_ALLOWED_CUR_CONSUMPTION), _microamps) ), cv.Optional(CONF_PROTECT_TIME, default="1000ms"): cv.templatable( cv.All( @@ -165,60 +154,22 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( ) -@automation.register_action( +# i2c_write_config() must follow the setters: it flushes the staged values. +automation.register_apply_action( "at581x.settings", - AT581XSettingsAction, RADAR_SETTINGS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "set_frequency", cg.int_), + automation.ApplyField(CONF_SENSING_DISTANCE, "set_sensing_distance", cg.int_), + automation.ApplyField( + CONF_POWERON_SELFCHECK_TIME, "set_poweron_selfcheck_time", cg.int_ + ), + automation.ApplyField(CONF_POWER_CONSUMPTION, "set_power_consumption", cg.int_), + automation.ApplyField(CONF_PROTECT_TIME, "set_protect_time", cg.int_), + automation.ApplyField(CONF_TRIGGER_BASE, "set_trigger_base", cg.int_), + automation.ApplyField(CONF_TRIGGER_KEEP, "set_trigger_keep", cg.int_), + automation.ApplyField(CONF_STAGE_GAIN, "set_stage_gain", cg.int_), + automation.ApplyCall("i2c_write_config()"), + automation.ApplyField( + CONF_HW_FRONTEND_RESET, "reset_hardware_frontend_if", cg.bool_ + ), ) -async def at581x_settings_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - # Radar configuration - if frontend_reset := config.get(CONF_HW_FRONTEND_RESET): - template_ = await cg.templatable(frontend_reset, args, cg.int8) - cg.add(var.set_hw_frontend_reset(template_)) - - if freq := config.get(CONF_FREQUENCY): - if not cg.is_template(freq): - freq = int(freq / 1000000) - template_ = await cg.templatable(freq, args, cg.int_) - cg.add(var.set_frequency(template_)) - - if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: - template_ = await cg.templatable(sens_dist, args, cg.int_) - cg.add(var.set_sensing_distance(template_)) - - if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int_) - cg.add(var.set_poweron_selfcheck_time(template_)) - - if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int_) - cg.add(var.set_protect_time(template_)) - - if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int_) - cg.add(var.set_trigger_base(template_)) - - if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int_) - cg.add(var.set_trigger_keep(template_)) - - if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: - template_ = await cg.templatable(stage_gain, args, cg.int_) - cg.add(var.set_stage_gain(template_)) - - if power := config.get(CONF_POWER_CONSUMPTION): - if not cg.is_template(power): - power = int(power * 1000000) - template_ = await cg.templatable(power, args, cg.int_) - cg.add(var.set_power_consumption(template_)) - - return var diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index 594395e96d8..df50d5142f8 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -38,6 +38,10 @@ class AT581XComponent final : public Component, public i2c::I2CDevice { bool i2c_write_config(); bool reset_hardware_frontend(); + void reset_hardware_frontend_if(bool reset) { + if (reset) + this->reset_hardware_frontend(); + } bool i2c_write_reg(uint8_t addr, uint8_t data); bool i2c_write_reg(uint8_t addr, uint32_t data); bool i2c_write_reg(uint8_t addr, uint16_t data); diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h deleted file mode 100644 index a732d2bcc79..00000000000 --- a/esphome/components/at581x/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "at581x.h" - -namespace esphome::at581x { - -template class AT581XResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } -}; - -template class AT581XSettingsAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) - TEMPLATABLE_VALUE(int, frequency) - TEMPLATABLE_VALUE(int, sensing_distance) - TEMPLATABLE_VALUE(int, poweron_selfcheck_time) - TEMPLATABLE_VALUE(int, power_consumption) - TEMPLATABLE_VALUE(int, protect_time) - TEMPLATABLE_VALUE(int, trigger_base) - TEMPLATABLE_VALUE(int, trigger_keep) - TEMPLATABLE_VALUE(int, stage_gain) - - void play(const Ts &...x) { - if (this->frequency_.has_value()) { - int v = this->frequency_.value(x...); - this->parent_->set_frequency(v); - } - if (this->sensing_distance_.has_value()) { - int v = this->sensing_distance_.value(x...); - this->parent_->set_sensing_distance(v); - } - if (this->poweron_selfcheck_time_.has_value()) { - int v = this->poweron_selfcheck_time_.value(x...); - this->parent_->set_poweron_selfcheck_time(v); - } - if (this->power_consumption_.has_value()) { - int v = this->power_consumption_.value(x...); - this->parent_->set_power_consumption(v); - } - if (this->protect_time_.has_value()) { - int v = this->protect_time_.value(x...); - this->parent_->set_protect_time(v); - } - if (this->trigger_base_.has_value()) { - int v = this->trigger_base_.value(x...); - this->parent_->set_trigger_base(v); - } - if (this->trigger_keep_.has_value()) { - int v = this->trigger_keep_.value(x...); - this->parent_->set_trigger_keep(v); - } - if (this->stage_gain_.has_value()) { - int v = this->stage_gain_.value(x...); - this->parent_->set_stage_gain(v); - } - - // This actually perform all the modification on the system - this->parent_->i2c_write_config(); - - if (this->hw_frontend_reset_.has_value() && this->hw_frontend_reset_.value(x...) == true) { - this->parent_->reset_hardware_frontend(); - } - } -}; -} // namespace esphome::at581x From 3b570d84aede6c2a9238610a0f0f8b7f9b4289dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:19:46 +0100 Subject: [PATCH 349/759] [rf_bridge] Use register_apply_action for the actions (#19497) --- esphome/components/rf_bridge/__init__.py | 130 ++++++----------------- esphome/components/rf_bridge/rf_bridge.h | 103 ------------------ tests/components/rf_bridge/common.yaml | 3 + 3 files changed, 36 insertions(+), 200 deletions(-) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9863379b791..fed8365e9ee 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -25,29 +25,6 @@ RFBridgeComponent = rf_bridge_ns.class_( RFBridgeData = rf_bridge_ns.struct("RFBridgeData") RFBridgeAdvancedData = rf_bridge_ns.struct("RFBridgeAdvancedData") -RFBridgeSendCodeAction = rf_bridge_ns.class_( - "RFBridgeSendCodeAction", automation.Action -) -RFBridgeSendAdvancedCodeAction = rf_bridge_ns.class_( - "RFBridgeSendAdvancedCodeAction", automation.Action -) - -RFBridgeLearnAction = rf_bridge_ns.class_("RFBridgeLearnAction", automation.Action) - -RFBridgeStartAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartAdvancedSniffingAction", automation.Action -) -RFBridgeStopAdvancedSniffingAction = rf_bridge_ns.class_( - "RFBridgeStopAdvancedSniffingAction", automation.Action -) - -RFBridgeStartBucketSniffingAction = rf_bridge_ns.class_( - "RFBridgeStartBucketSniffingAction", automation.Action -) - -RFBridgeBeepAction = rf_bridge_ns.class_("RFBridgeBeepAction", automation.Action) - -RFBridgeSendRawAction = rf_bridge_ns.class_("RFBridgeSendRawAction", automation.Action) CONF_ON_CODE_RECEIVED = "on_code_received" CONF_ON_ADVANCED_CODE_RECEIVED = "on_advanced_code_received" @@ -110,74 +87,48 @@ RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_code", - RFBridgeSendCodeAction, RFBRIDGE_SEND_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_code(rf_bridge::RFBridgeData{{.sync = {}, .low = {}, .high = {}, .code = {}}})", + ( + (CONF_SYNC, cg.uint16), + (CONF_LOW, cg.uint16), + (CONF_HIGH, cg.uint16), + (CONF_CODE, cg.uint32), + ), + ), ) -async def rf_bridge_send_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_SYNC], args, cg.uint16) - cg.add(var.set_sync(template_)) - template_ = await cg.templatable(config[CONF_LOW], args, cg.uint16) - cg.add(var.set_low(template_)) - template_ = await cg.templatable(config[CONF_HIGH], args, cg.uint16) - cg.add(var.set_high(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) - return var RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) -@automation.register_action( - "rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.learn", RFBRIDGE_ID_SCHEMA, automation.ApplyCall("learn()") ) -async def rf_bridge_learnx_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_advanced_sniffing", - RFBridgeStartAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_advanced_sniffing()"), ) -async def rf_bridge_start_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.stop_advanced_sniffing", - RFBridgeStopAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_advanced_sniffing()"), ) -async def rf_bridge_stop_advanced_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) -@automation.register_action( +automation.register_apply_action( "rf_bridge.start_bucket_sniffing", - RFBridgeStartBucketSniffingAction, RFBRIDGE_ID_SCHEMA, - synchronous=True, + automation.ApplyCall("start_bucket_sniffing()"), ) -async def rf_bridge_start_bucket_sniffing_to_code( - config, action_id, template_args, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_args, paren) RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( @@ -190,22 +141,18 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_advanced_code", - RFBridgeSendAdvancedCodeAction, RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_advanced_code(rf_bridge::RFBridgeAdvancedData{{.length = {}, .protocol = {}, .code = {}}})", + ( + (CONF_LENGTH, cg.uint8), + (CONF_PROTOCOL, cg.uint8), + (CONF_CODE, cg.std_string), + ), + ), ) -async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_LENGTH], args, cg.uint8) - cg.add(var.set_length(template_)) - template_ = await cg.templatable(config[CONF_PROTOCOL], args, cg.uint8) - cg.add(var.set_protocol(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(template_)) - return var RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( @@ -216,18 +163,11 @@ RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "rf_bridge.send_raw", - RFBridgeSendRawAction, RFBRIDGE_SEND_RAW_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_RAW, "send_raw", cg.std_string), ) -async def rf_bridge_send_raw_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_RAW], args, cg.std_string) - cg.add(var.set_raw(template_)) - return var RFBRIDGE_BEEP_SCHEMA = cv.Schema( @@ -238,12 +178,8 @@ RFBRIDGE_BEEP_SCHEMA = cv.Schema( ) -@automation.register_action( - "rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA, synchronous=True +automation.register_apply_action( + "rf_bridge.beep", + RFBRIDGE_BEEP_SCHEMA, + automation.ApplyField(CONF_DURATION, "beep", cg.uint16), ) -async def rf_bridge_beep_to_code(config, action_id, template_args, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_args, paren) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint16) - cg.add(var.set_duration(template_)) - return var diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index cbb1880ec53..200ef978109 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -5,7 +5,6 @@ #include "esphome/core/component.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" namespace esphome::rf_bridge { @@ -89,106 +88,4 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction final : public Action { - public: - RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, sync) - TEMPLATABLE_VALUE(uint16_t, low) - TEMPLATABLE_VALUE(uint16_t, high) - TEMPLATABLE_VALUE(uint32_t, code) - - void play(const Ts &...x) { - RFBridgeData data{}; - data.sync = this->sync_.value(x...); - data.low = this->low_.value(x...); - data.high = this->high_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendAdvancedCodeAction final : public Action { - public: - RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint8_t, length) - TEMPLATABLE_VALUE(uint8_t, protocol) - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) { - RFBridgeAdvancedData data{}; - data.length = this->length_.value(x...); - data.protocol = this->protocol_.value(x...); - data.code = this->code_.value(x...); - this->parent_->send_advanced_code(data); - } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeLearnAction final : public Action { - public: - RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->learn(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartAdvancedSniffingAction final : public Action { - public: - RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStopAdvancedSniffingAction final : public Action { - public: - RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->stop_advanced_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeStartBucketSniffingAction final : public Action { - public: - RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->start_bucket_sniffing(); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeSendRawAction final : public Action { - public: - RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, raw) - - void play(const Ts &...x) { this->parent_->send_raw(this->raw_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - -template class RFBridgeBeepAction final : public Action { - public: - RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, duration) - - void play(const Ts &...x) { this->parent_->beep(this->duration_.value(x...)); } - - protected: - RFBridgeComponent *parent_; -}; - } // namespace esphome::rf_bridge diff --git a/tests/components/rf_bridge/common.yaml b/tests/components/rf_bridge/common.yaml index 427c3d783d7..2ad0c4fa9db 100644 --- a/tests/components/rf_bridge/common.yaml +++ b/tests/components/rf_bridge/common.yaml @@ -27,3 +27,6 @@ rf_bridge: code: "ABC123" - rf_bridge.send_raw: raw: "AAA5070008001000ABC12355" + - rf_bridge.start_bucket_sniffing: + - rf_bridge.beep: + duration: !lambda return 100; From 1a8a661b5656c2578add5a7344afabe1cacc1cbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:22:05 +0100 Subject: [PATCH 350/759] [rtttl] Use register_apply_action for rtttl.play and rtttl.stop (#19482) --- esphome/components/rtttl/__init__.py | 46 +++------------------------- esphome/components/rtttl/rtttl.h | 16 ---------- tests/components/rtttl/common.yaml | 14 +++++++++ 3 files changed, 19 insertions(+), 57 deletions(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index b6c41835864..4da64a8304d 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -6,8 +6,6 @@ from esphome.components.output import FloatOutput from esphome.components.speaker import Speaker import esphome.config_validation as cv from esphome.const import CONF_GAIN, CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_SPEAKER -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -20,8 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -PlayAction = rtttl_ns.class_("PlayAction", automation.Action) -StopAction = rtttl_ns.class_("StopAction", automation.Action) IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -101,9 +97,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "rtttl.play", - PlayAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), @@ -111,43 +106,21 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_RTTTL, ), - synchronous=True, + automation.ApplyField(CONF_RTTTL, "play", cg.std_string), ) -async def rtttl_play_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RTTTL], args, cg.std_string) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "rtttl.stop", - StopAction, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def rtttl_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_condition( +automation.register_parented_condition( "rtttl.is_playing", IsPlayingCondition, cv.Schema( @@ -156,12 +129,3 @@ async def rtttl_stop_to_code( } ), ) -async def rtttl_is_playing_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 256bdce5f21..0bf2fce70f5 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -116,22 +116,6 @@ class Rtttl final : public Component { #endif }; -template class PlayAction final : public Action { - public: - PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { this->rtttl_->play(this->value_.value(x...)); } - - protected: - Rtttl *rtttl_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index a4d8f951f42..87c1eebdf0d 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -19,6 +19,19 @@ esphome: - rtttl.play: 'test_only_o:o=5:c' - rtttl.play: 'test_only_b:b=100:c' - rtttl.play: 'test_empty::c' + # Long form with an explicit id, a lambda song, and the is_playing condition + - rtttl.play: + id: rtttl_player + rtttl: 'test_long_form:d=8,o=5,b=100:c' + - rtttl.play: + rtttl: !lambda return std::string("test_lambda:d=8,o=5,b=100:c"); + - if: + condition: + rtttl.is_playing: + id: rtttl_player + then: + - rtttl.stop: + id: rtttl_player output: - platform: ${output_platform} @@ -28,6 +41,7 @@ output: max_power: 0.5 rtttl: + id: rtttl_player output: rtttl_output on_finished_playback: - then: From 3da75331598d15a66f863516df8a504e539ca5db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 12:24:30 +0100 Subject: [PATCH 351/759] [remote_base] Generate the gobox code as uint64_t (#19478) --- esphome/components/remote_base/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index bf8707ff1e8..e542ba3ed8f 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1149,7 +1149,7 @@ def gobox_dumper(var, config): @register_action("gobox", GoboxAction, GOBOX_SCHEMA) async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.int_) + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) cg.add(var.set_code(template_)) From 26abc1325efa7016a8480dc0cdce42042e871b93 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 23 Sep 2026 13:48:39 +0100 Subject: [PATCH 352/759] [nrf52] disable USB if not configured by esphome to save power (#19493) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nrf52/__init__.py | 8 ++++++++ esphome/components/zephyr/__init__.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index aeeaba0c114..be8768fd154 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -428,6 +428,10 @@ async def to_code(config: ConfigType) -> None: ) zephyr_add_prj_conf("REBOOT", True) + # some boards enable USB by default. + # disable it to prevent extra current consumption. + zephyr_add_prj_conf("USB_DEVICE_STACK", False, False) + @coroutine_with_priority(CoroPriority.DIAGNOSTICS) async def _dfu_to_code(dfu_config): @@ -436,6 +440,10 @@ async def _dfu_to_code(dfu_config): if CONF_RESET_PIN in dfu_config: pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) cg.add(var.set_reset_pin(pin)) + + # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. + zephyr_add_prj_conf("USB_DEVICE_STACK", True) + zephyr_add_prj_conf("USB_CDC_ACM", True) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) await cg.register_component(var, dfu_config) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 29ba0b9ed21..2bcdf0380c7 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -176,7 +176,10 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: - if "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT" in zephyr_data()[KEY_PRJ_CONF][""]: + need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( + "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) + )[0] + if need_cdc_cb: var = cg.new_Pvariable(config[CONF_CDC_ACM]) await cg.register_component(var, {}) From 93df1e125970a93bc710c9d9b48ea29048bc6cbc Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 12:29:23 -0300 Subject: [PATCH 353/759] [gree] Remove the traits() override now handled by climate_ir (#19484) --- esphome/components/gree/gree.cpp | 11 ----------- esphome/components/gree/gree.h | 1 - tests/components/gree/__init__.py | 6 ++++++ tests/components/gree/gree_test.cpp | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 tests/components/gree/__init__.py create mode 100644 tests/components/gree/gree_test.cpp diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 634a2d33b5e..6add618ecb8 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,17 +5,6 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; -climate::ClimateTraits GreeClimate::traits() { - auto t = climate_ir::ClimateIR::traits(); - // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. - if (!this->supports_heat_) { - auto modes = t.get_supported_modes(); - modes.erase(climate::CLIMATE_MODE_HEAT_COOL); - t.set_supported_modes(modes); - } - return t; -} - void GreeClimate::set_model(Model model) { if (model == GREE_YAN) { // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 2f879269428..c2616295735 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -94,7 +94,6 @@ class GreeClimate final : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; - climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); diff --git a/tests/components/gree/__init__.py b/tests/components/gree/__init__.py new file mode 100644 index 00000000000..8e15f6d479b --- /dev/null +++ b/tests/components/gree/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/gree/gree_test.cpp b/tests/components/gree/gree_test.cpp new file mode 100644 index 00000000000..9a02c60b39c --- /dev/null +++ b/tests/components/gree/gree_test.cpp @@ -0,0 +1,24 @@ +#include +#include "esphome/components/gree/gree.h" + +namespace esphome::gree::testing { + +TEST(GreeClimateTest, HeatCoolHiddenWithoutHeatByDefault) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + EXPECT_FALSE(climate.get_traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); +} + +TEST(GreeClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + GreeClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +} // namespace esphome::gree::testing From 9c5479287ed32acbf91aada89408c78599430de5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:29:47 +0000 Subject: [PATCH 354/759] Bump bundled esphome-device-builder to 1.15.0 (#19526) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 4a177ad0069..d94a7ce41a7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.15.0 RUN \ platformio settings set enable_telemetry No \ From b36c6bd7cae365ba668c48b0b1139abe76afeed0 Mon Sep 17 00:00:00 2001 From: myztillx <33730898+myztillx@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:58:06 -0400 Subject: [PATCH 355/759] [dfplayer] Add set_current_track_repeat action (#9186) Co-authored-by: J. Nick Koston --- esphome/components/dfplayer/__init__.py | 29 ++++++++++++++++++++++++ esphome/components/dfplayer/dfplayer.cpp | 7 ++++++ esphome/components/dfplayer/dfplayer.h | 1 + tests/components/dfplayer/common.yaml | 9 ++++++++ 4 files changed, 46 insertions(+) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index bb18e6ba8c6..afe8c726759 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import uart @@ -16,6 +18,7 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" +CONF_ENABLE = "enable" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" @@ -375,6 +378,32 @@ async def dfplayer_random_to_code(config, action_id, template_arg, args): return var +def _default_enable(value: Any) -> Any: + """Fill in ``enable: true`` for a bare action or a mapping that only picks the player. + + Done before ``maybe_simple_value`` so neither form is wrapped as the ``enable`` value. + """ + if value is None or isinstance(value, dict): + return {CONF_ENABLE: True, **(value or {})} + return value + + +automation.register_apply_action( + "dfplayer.set_current_track_repeat", + cv.All( + _default_enable, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(DFPlayer), + cv.Optional(CONF_ENABLE, default=True): cv.templatable(cv.boolean), + }, + key=CONF_ENABLE, + ), + ), + automation.ApplyField(CONF_ENABLE, "set_current_track_repeat", cg.bool_), +) + + @automation.register_condition( "dfplayer.is_playing", DFPlayerIsPlayingCondition, diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index f81d1cd1b6a..1195f595c8c 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -102,6 +102,13 @@ void DFPlayer::random() { this->send_cmd_(0x18); } +void DFPlayer::set_current_track_repeat(bool enable) { + uint16_t arg = enable ? 0x00 : 0x01; + ESP_LOGD(TAG, "Setting current track repeat to %s", + enable ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); + this->send_cmd_(0x19, arg); +} + void DFPlayer::play_folder(uint16_t folder, uint16_t file) { ESP_LOGD(TAG, "Playing file %d in folder %d", file, folder); if (folder < 100 && file < 256) { diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 1db6b394c58..66cf9294088 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -46,6 +46,7 @@ class DFPlayer final : public uart::UARTDevice, public Component { void pause(); void stop(); void random(); + void set_current_track_repeat(bool enable); bool is_playing() { return is_playing_; } void dump_config() override; diff --git a/tests/components/dfplayer/common.yaml b/tests/components/dfplayer/common.yaml index 5d2540c2753..940f07a2ed3 100644 --- a/tests/components/dfplayer/common.yaml +++ b/tests/components/dfplayer/common.yaml @@ -25,8 +25,17 @@ esphome: - dfplayer.volume_up - dfplayer.volume_down - dfplayer.sleep + - dfplayer.set_current_track_repeat + - dfplayer.set_current_track_repeat: true + - dfplayer.set_current_track_repeat: + enable: false + - dfplayer.set_current_track_repeat: + enable: !lambda return true; + - dfplayer.set_current_track_repeat: + id: dfplayer_test dfplayer: + id: dfplayer_test on_finished_playback: then: if: From 35607e99a76f4c3340292cdbfe8e177974216871 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 23 Sep 2026 13:18:41 -0300 Subject: [PATCH 356/759] [mitsubishi] Use the climate_ir traits() for supported modes (#19485) --- esphome/components/mitsubishi/mitsubishi.cpp | 40 ------------ esphome/components/mitsubishi/mitsubishi.h | 19 ++++-- tests/components/mitsubishi/__init__.py | 6 ++ .../components/mitsubishi/mitsubishi_test.cpp | 65 +++++++++++++++++++ 4 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 tests/components/mitsubishi/__init__.py create mode 100644 tests/components/mitsubishi/mitsubishi_test.cpp diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index d108ea46215..d7c4e7f1a1d 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -53,46 +53,6 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; -climate::ClimateTraits MitsubishiClimate::traits() { - auto traits = climate::ClimateTraits(); - if (this->sensor_ != nullptr) { - traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - } - traits.set_visual_min_temperature(MITSUBISHI_TEMP_MIN); - traits.set_visual_max_temperature(MITSUBISHI_TEMP_MAX); - traits.set_visual_temperature_step(1.0f); - traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); - - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - - if (this->supports_cool_ && this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); - - // Default to only 3 levels in ESPHome even if most unit supports 4. The 3rd level is not used. - traits.set_supported_fan_modes( - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}); - if (this->fan_mode_ == MITSUBISHI_FAN_Q4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_QUIET); - if (/*this->fan_mode_ == MITSUBISHI_FAN_5L ||*/ this->fan_mode_ >= MITSUBISHI_FAN_4L) - traits.add_supported_fan_mode(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps - - traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}); - - traits.set_supported_presets({climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, - climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}); - - return traits; -} - void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 657da900e28..5b61beac4e3 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,12 +38,14 @@ enum VerticalDirection : uint8_t { VERTICAL_DIRECTION_DOWN = 0x28, }; +// Fan modes offered by every set_fan_mode option. +static constexpr climate::ClimateFanModeMask MITSUBISHI_BASE_FAN_MODES{ + climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}; + class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() - : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MIDDLE, - climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_QUIET}, + : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, MITSUBISHI_BASE_FAN_MODES, {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL}, {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, @@ -54,7 +56,14 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; } + void set_fan_mode(SetFanMode fan_mode) { + this->fan_mode_ = fan_mode; + this->fan_modes_ = MITSUBISHI_BASE_FAN_MODES; + if (fan_mode == MITSUBISHI_FAN_Q4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); + if (fan_mode >= MITSUBISHI_FAN_4L) + this->fan_modes_.insert(climate::CLIMATE_FAN_MIDDLE); // Shouldn't be used for this but it helps + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; @@ -74,8 +83,6 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; - - climate::ClimateTraits traits() override; }; } // namespace esphome::mitsubishi diff --git a/tests/components/mitsubishi/__init__.py b/tests/components/mitsubishi/__init__.py new file mode 100644 index 00000000000..8e15f6d479b --- /dev/null +++ b/tests/components/mitsubishi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # AUTO_LOAD sits on the climate platform, which the unit test build does not load. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/mitsubishi/mitsubishi_test.cpp b/tests/components/mitsubishi/mitsubishi_test.cpp new file mode 100644 index 00000000000..3bc84a5db92 --- /dev/null +++ b/tests/components/mitsubishi/mitsubishi_test.cpp @@ -0,0 +1,65 @@ +#include +#include "esphome/components/mitsubishi/mitsubishi.h" + +namespace esphome::mitsubishi::testing { + +TEST(MitsubishiClimateTest, HeatCoolOverrideAdvertisedWithoutHeat) { + MitsubishiClimate climate; + climate.set_supports_heat(false); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(true); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); +} + +TEST(MitsubishiClimateTest, HeatCoolOverrideHiddenWithHeatAndCool) { + MitsubishiClimate climate; + climate.set_supports_heat(true); + climate.set_supports_cool(true); + climate.set_supports_heat_cool(false); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); +} + +TEST(MitsubishiClimateTest, FanModesFor3Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_AUTO)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_LOW)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MEDIUM)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFor4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesForQuietAnd4Levels) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + auto traits = climate.get_traits(); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); +} + +TEST(MitsubishiClimateTest, FanModesFollowTheLastSetFanMode) { + MitsubishiClimate climate; + climate.set_fan_mode(MITSUBISHI_FAN_Q4L); + climate.set_fan_mode(MITSUBISHI_FAN_3L); + auto traits = climate.get_traits(); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_MIDDLE)); + EXPECT_FALSE(traits.supports_fan_mode(climate::CLIMATE_FAN_QUIET)); + EXPECT_TRUE(traits.supports_fan_mode(climate::CLIMATE_FAN_HIGH)); +} + +} // namespace esphome::mitsubishi::testing From f6e97b7adaa6225fd2d8296acab0e4771b487156 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 23 Sep 2026 18:19:09 +0200 Subject: [PATCH 357/759] [RX8025T] add RX8025T realtime clock (#13274) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/rx8025t/__init__.py | 0 esphome/components/rx8025t/rx8025t.cpp | 128 ++++++++++++++++++ esphome/components/rx8025t/rx8025t.h | 21 +++ esphome/components/rx8025t/time.py | 42 ++++++ tests/components/rx8025t/common.yaml | 12 ++ tests/components/rx8025t/test.esp32-idf.yaml | 4 + .../components/rx8025t/test.esp8266-ard.yaml | 4 + .../rx8025t/test.nrf52-adafruit.yaml | 4 + tests/components/rx8025t/test.rp2040-ard.yaml | 4 + 10 files changed, 220 insertions(+) create mode 100644 esphome/components/rx8025t/__init__.py create mode 100644 esphome/components/rx8025t/rx8025t.cpp create mode 100644 esphome/components/rx8025t/rx8025t.h create mode 100644 esphome/components/rx8025t/time.py create mode 100644 tests/components/rx8025t/common.yaml create mode 100644 tests/components/rx8025t/test.esp32-idf.yaml create mode 100644 tests/components/rx8025t/test.esp8266-ard.yaml create mode 100644 tests/components/rx8025t/test.nrf52-adafruit.yaml create mode 100644 tests/components/rx8025t/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index aba498c3652..5c7063f0a54 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -457,6 +457,7 @@ esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco +esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny diff --git a/esphome/components/rx8025t/__init__.py b/esphome/components/rx8025t/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp new file mode 100644 index 00000000000..fee2c83a5fe --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -0,0 +1,128 @@ +#include "rx8025t.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://support.epson.biz/td/api/doc_check.php?dl=app_RX8025T + +namespace esphome::rx8025t { + +static constexpr uint8_t RX8025T_REG_SEC = 0x00; +static constexpr uint8_t RX8025T_REG_FLAG = 0x0E; +static constexpr uint8_t RX8025T_FLAG_VDET = 0x01; +static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; + +static const char *const TAG = "rx8025t"; + +constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } +constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } + +void RX8025TComponent::setup() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + this->mark_failed(); + return; + } + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - Loss of oscillator detected. Time may be invalid."); + } +} + +void RX8025TComponent::update() { this->read_time(); } + +void RX8025TComponent::dump_config() { + ESP_LOGCONFIG(TAG, "RX8025T:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + time::RealTimeClock::dump_config(); +} + +bool RX8025TComponent::read_flags_(uint8_t *flags) { + if (!this->read_byte(RX8025T_REG_FLAG, flags)) { + ESP_LOGE(TAG, "Can't read flag register."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + return true; +} + +void RX8025TComponent::read_time() { + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + + uint8_t date[7]; + if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + ESP_LOGE(TAG, "Can't read I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + if (flags & RX8025T_FLAG_VLF) { + ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); + return; + } + + ESPTime rtc_time{ + .second = bcd2dec(date[0] & 0x7f), + .minute = bcd2dec(date[1] & 0x7f), + .hour = bcd2dec(date[2] & 0x3f), + .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), + .day_of_month = bcd2dec(date[4] & 0x3f), + .day_of_year = 1, + .month = bcd2dec(date[5] & 0x1f), + .year = static_cast(bcd2dec(date[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d VDET:%s", rtc_time.year, rtc_time.month, + rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, ONOFF(flags & RX8025T_FLAG_VDET)); + this->synchronize_epoch_(rtc_time.timestamp); +} + +void RX8025TComponent::write_time() { + auto now = this->utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + + uint8_t buff[7]; + buff[0] = dec2bcd(now.second); + buff[1] = dec2bcd(now.minute); + buff[2] = dec2bcd(now.hour); + buff[3] = 1 << (now.day_of_week - 1); + buff[4] = dec2bcd(now.day_of_month); + buff[5] = dec2bcd(now.month); + buff[6] = dec2bcd(now.year % 100); + if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { + ESP_LOGE(TAG, "Can't write I2C data."); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); + + // Clear VLF and VDET flags via read-modify-write of flag register + uint8_t flags; + if (!this->read_flags_(&flags)) { + return; + } + flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); + if (!this->write_byte(RX8025T_REG_FLAG, flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); +} + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/rx8025t.h b/esphome/components/rx8025t/rx8025t.h new file mode 100644 index 00000000000..9c53fe6971d --- /dev/null +++ b/esphome/components/rx8025t/rx8025t.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8025t { + +class RX8025TComponent : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool read_flags_(uint8_t *flags); +}; + +} // namespace esphome::rx8025t diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py new file mode 100644 index 00000000000..e8ffe772ab7 --- /dev/null +++ b/esphome/components/rx8025t/time.py @@ -0,0 +1,42 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] + +rx8025t_ns = cg.esphome_ns.namespace("rx8025t") +RX8025TComponent = rx8025t_ns.class_( + "RX8025TComponent", time.RealTimeClock, i2c.I2CDevice +) +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8025TComponent), + } +).extend(i2c.i2c_device_schema(0x32)) + + +for _name, _call in ( + ("rx8025t.write_time", "write_time()"), + ("rx8025t.read_time", "read_time()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8025TComponent), + } + ), + automation.ApplyCall(_call), + ) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/tests/components/rx8025t/common.yaml b/tests/components/rx8025t/common.yaml new file mode 100644 index 00000000000..b8c7b6a1a2f --- /dev/null +++ b/tests/components/rx8025t/common.yaml @@ -0,0 +1,12 @@ +esphome: + on_boot: + then: + - rx8025t.write_time: rx8025t_time + - rx8025t.read_time: + id: rx8025t_time + +time: + - platform: rx8025t + i2c_id: i2c_bus + id: rx8025t_time + update_interval: never diff --git a/tests/components/rx8025t/test.esp32-idf.yaml b/tests/components/rx8025t/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/rx8025t/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.esp8266-ard.yaml b/tests/components/rx8025t/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/rx8025t/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.nrf52-adafruit.yaml b/tests/components/rx8025t/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..2a0de6241c3 --- /dev/null +++ b/tests/components/rx8025t/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8025t/test.rp2040-ard.yaml b/tests/components/rx8025t/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/rx8025t/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From cf3552b46ef88c077e732784038581a2d5e4cebe Mon Sep 17 00:00:00 2001 From: ngolf <74095787+ngolf@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:21:25 +0100 Subject: [PATCH 358/759] [pid] Pid climate add set deadband parameters action (#14730) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pid/climate.py | 99 ++++++++++++------- esphome/components/pid/pid_climate.cpp | 11 +++ esphome/components/pid/pid_climate.h | 33 +------ esphome/components/pid/sensor/__init__.py | 72 +++++++++++--- .../pid/sensor/pid_climate_sensor.cpp | 40 +++++--- .../pid/sensor/pid_climate_sensor.h | 5 + tests/components/pid/__init__.py | 5 + tests/components/pid/common.yaml | 29 ++++++ .../pid/test_deadband_thresholds.cpp | 41 ++++++++ 9 files changed, 236 insertions(+), 99 deletions(-) create mode 100644 tests/components/pid/__init__.py create mode 100644 tests/components/pid/test_deadband_thresholds.cpp diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 4945547f2eb..11a6fd5e6bd 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,19 +3,13 @@ import esphome.codegen as cg from esphome.components import climate, output, sensor import esphome.config_validation as cv from esphome.const import CONF_HUMIDITY_SENSOR, CONF_ID, CONF_SENSOR -from esphome.core import ID +from esphome.core import ID, Lambda from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action) -PIDResetIntegralTermAction = pid_ns.class_( - "PIDResetIntegralTermAction", automation.Action -) -PIDSetControlParametersAction = pid_ns.class_( - "PIDSetControlParametersAction", automation.Action -) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -43,6 +37,24 @@ CONF_KP_MULTIPLIER = "kp_multiplier" CONF_KI_MULTIPLIER = "ki_multiplier" CONF_KD_MULTIPLIER = "kd_multiplier" + +def _validate_thresholds(config: ConfigType) -> ConfigType: + # Same rule as PIDClimate::set_deadband_thresholds; equal is allowed since 0/0 is the default. + if config[CONF_THRESHOLD_LOW] > config[CONF_THRESHOLD_HIGH]: + raise cv.Invalid( + f"{CONF_THRESHOLD_LOW} must not be greater than {CONF_THRESHOLD_HIGH}" + ) + return config + + +def _validate_threshold_action(config: ConfigType) -> ConfigType: + threshold_low = config[CONF_THRESHOLD_LOW] + threshold_high = config[CONF_THRESHOLD_HIGH] + if isinstance(threshold_low, Lambda) or isinstance(threshold_high, Lambda): + return config + return _validate_thresholds(config) + + CONFIG_SCHEMA = cv.All( climate.climate_schema(PIDClimate).extend( { @@ -51,7 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_DEFAULT_TARGET_TEMPERATURE): cv.temperature, cv.Optional(CONF_COOL_OUTPUT): cv.use_id(output.FloatOutput), cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput), - cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema( + cv.Optional(CONF_DEADBAND_PARAMETERS): cv.All( { cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta, cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta, @@ -61,7 +73,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1 ): cv.positive_not_null_int, - } + }, + _validate_thresholds, ), cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema( { @@ -134,24 +147,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE])) -@automation.register_action( +automation.register_apply_action( "climate.pid.reset_integral_term", - PIDResetIntegralTermAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), } ), - synchronous=True, + automation.ApplyCall("reset_integral_term()"), ) -async def pid_reset_integral_term( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -185,9 +189,8 @@ async def esp8266_set_frequency_to_code( return var -@automation.register_action( +automation.register_apply_action( "climate.pid.set_control_parameters", - PIDSetControlParametersAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -196,24 +199,44 @@ async def esp8266_set_frequency_to_code( cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + automation.ApplyField(CONF_KD, "set_kd", cg.float_), ) -async def set_control_parameters( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - kp_template_ = await cg.templatable(config[CONF_KP], args, cg.float_) - cg.add(var.set_kp(kp_template_)) - ki_template_ = await cg.templatable(config[CONF_KI], args, cg.float_) - cg.add(var.set_ki(ki_template_)) +automation.register_apply_action( + "climate.pid.set_deadband_control_parameters_multipliers", + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + # kp_multiplier is required for compatibility with the original action API; + # ki_multiplier and kd_multiplier are optional overrides. + cv.Required(CONF_KP_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KI_MULTIPLIER): cv.templatable(cv.float_), + cv.Optional(CONF_KD_MULTIPLIER): cv.templatable(cv.float_), + } + ), + automation.ApplyField(CONF_KP_MULTIPLIER, "set_kp_multiplier", cg.float_), + automation.ApplyField(CONF_KI_MULTIPLIER, "set_ki_multiplier", cg.float_), + automation.ApplyField(CONF_KD_MULTIPLIER, "set_kd_multiplier", cg.float_), +) - kd_template_ = await cg.templatable(config[CONF_KD], args, cg.float_) - cg.add(var.set_kd(kd_template_)) - return var +automation.register_apply_action( + "climate.pid.set_deadband_threshold_parameters", + automation.maybe_simple_id( + cv.All( + { + cv.Required(CONF_ID): cv.use_id(PIDClimate), + cv.Required(CONF_THRESHOLD_HIGH): cv.templatable(cv.temperature_delta), + cv.Required(CONF_THRESHOLD_LOW): cv.templatable(cv.temperature_delta), + }, + _validate_threshold_action, + ) + ), + automation.ApplyCall( + "set_deadband_thresholds({}, {})", + ((CONF_THRESHOLD_LOW, cg.float_), (CONF_THRESHOLD_HIGH, cg.float_)), + ), +) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 8c9231fda61..b386716780e 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -5,6 +5,17 @@ namespace esphome::pid { static const char *const TAG = "pid.climate"; +bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { + if (threshold_low > threshold_high) { + ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high); + return false; + } + + this->set_threshold_low(threshold_low); + this->set_threshold_high(threshold_high); + return true; +} + void PIDClimate::setup() { this->sensor_->add_on_state_callback([this](float state) { // only publish if state/current temperature has changed in two digits of precision diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 7269709ab9b..e8701c99a49 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -26,6 +26,7 @@ class PIDClimate final : public climate::Climate, public Component { void set_kd(float kd) { controller_.kd_ = kd; } void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; } void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; } + bool set_deadband_thresholds(float threshold_low, float threshold_high); void set_output_samples(int in) { controller_.output_samples_ = in; } void set_derivative_samples(int in) { controller_.derivative_samples_ = in; @@ -131,36 +132,4 @@ template class PIDAutotuneAction final : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction final : public Action { - public: - PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->reset_integral_term(); } - - protected: - PIDClimate *parent_; -}; - -template class PIDSetControlParametersAction final : public Action { - public: - PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} - - void play(const Ts &...x) { - auto kp = this->kp_.value(x...); - auto ki = this->ki_.value(x...); - auto kd = this->kd_.value(x...); - - this->parent_->set_kp(kp); - this->parent_->set_ki(ki); - this->parent_->set_kd(kd); - } - - protected: - TEMPLATABLE_VALUE(float, kp) - TEMPLATABLE_VALUE(float, ki) - TEMPLATABLE_VALUE(float, kd) - - PIDClimate *parent_; -}; - } // namespace esphome::pid diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index 94d641de47b..242a2440f87 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -2,7 +2,15 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.const import ( + CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, + ICON_GAUGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, +) from esphome.types import ConfigType from ..climate import PIDClimate, pid_ns @@ -11,22 +19,55 @@ PIDClimateSensor = pid_ns.class_("PIDClimateSensor", sensor.Sensor, cg.Component PIDClimateSensorType = pid_ns.enum("PIDClimateSensorType") PID_CLIMATE_SENSOR_TYPES = { - "RESULT": PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, - "ERROR": PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, - "PROPORTIONAL": PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, - "INTEGRAL": PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, - "DERIVATIVE": PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, - "HEAT": PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, - "COOL": PIDClimateSensorType.PID_SENSOR_TYPE_COOL, - "KP": PIDClimateSensorType.PID_SENSOR_TYPE_KP, - "KI": PIDClimateSensorType.PID_SENSOR_TYPE_KI, - "KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD, + "RESULT": (PIDClimateSensorType.PID_SENSOR_TYPE_RESULT, UNIT_PERCENT), + "ERROR": (PIDClimateSensorType.PID_SENSOR_TYPE_ERROR, UNIT_PERCENT), + "PROPORTIONAL": (PIDClimateSensorType.PID_SENSOR_TYPE_PROPORTIONAL, UNIT_PERCENT), + "INTEGRAL": (PIDClimateSensorType.PID_SENSOR_TYPE_INTEGRAL, UNIT_PERCENT), + "DERIVATIVE": (PIDClimateSensorType.PID_SENSOR_TYPE_DERIVATIVE, UNIT_PERCENT), + "HEAT": (PIDClimateSensorType.PID_SENSOR_TYPE_HEAT, UNIT_PERCENT), + "COOL": (PIDClimateSensorType.PID_SENSOR_TYPE_COOL, UNIT_PERCENT), + "KP": (PIDClimateSensorType.PID_SENSOR_TYPE_KP, UNIT_PERCENT), + "KI": (PIDClimateSensorType.PID_SENSOR_TYPE_KI, UNIT_PERCENT), + "KD": (PIDClimateSensorType.PID_SENSOR_TYPE_KD, UNIT_PERCENT), + "DEADBAND_THRESHOLD_HIGH": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + UNIT_CELSIUS, + ), + "DEADBAND_THRESHOLD_LOW": ( + PIDClimateSensorType.PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + UNIT_CELSIUS, + ), + "KP_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KI_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), + "KD_DEADBAND_MULTIPLIER": ( + PIDClimateSensorType.PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, + UNIT_EMPTY, + ), } -CONFIG_SCHEMA = ( +PID_CLIMATE_SENSOR_ENUMS = { + sensor_type: sensor_config[0] + for sensor_type, sensor_config in PID_CLIMATE_SENSOR_TYPES.items() +} + + +def set_default_unit_of_measurement(config: ConfigType) -> ConfigType: + sensor_type = config[CONF_TYPE] + config.setdefault( + CONF_UNIT_OF_MEASUREMENT, PID_CLIMATE_SENSOR_TYPES[sensor_type][1] + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( PIDClimateSensor, - unit_of_measurement=UNIT_PERCENT, icon=ICON_GAUGE, accuracy_decimals=1, state_class=STATE_CLASS_MEASUREMENT, @@ -34,10 +75,11 @@ CONFIG_SCHEMA = ( .extend( { cv.GenerateID(CONF_CLIMATE_ID): cv.use_id(PIDClimate), - cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_TYPES, upper=True), + cv.Required(CONF_TYPE): cv.enum(PID_CLIMATE_SENSOR_ENUMS, upper=True), } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + set_default_unit_of_measurement, ) diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index 4e963168e68..afc77efde33 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -14,43 +14,55 @@ void PIDClimateSensor::update_from_parent_() { float value; switch (this->type_) { case PID_SENSOR_TYPE_RESULT: - value = this->parent_->get_output_value(); + value = this->parent_->get_output_value() * 100.0f; break; case PID_SENSOR_TYPE_ERROR: - value = this->parent_->get_error_value(); + value = this->parent_->get_error_value() * 100.0f; break; case PID_SENSOR_TYPE_PROPORTIONAL: - value = this->parent_->get_proportional_term(); + value = this->parent_->get_proportional_term() * 100.0f; break; case PID_SENSOR_TYPE_INTEGRAL: - value = this->parent_->get_integral_term(); + value = this->parent_->get_integral_term() * 100.0f; break; case PID_SENSOR_TYPE_DERIVATIVE: - value = this->parent_->get_derivative_term(); + value = this->parent_->get_derivative_term() * 100.0f; break; case PID_SENSOR_TYPE_HEAT: - value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_COOL: - value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f); + value = clamp(-this->parent_->get_output_value(), 0.0f, 1.0f) * 100.0f; break; case PID_SENSOR_TYPE_KP: value = this->parent_->get_kp(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KI: value = this->parent_->get_ki(); - this->publish_state(value); - return; + break; case PID_SENSOR_TYPE_KD: value = this->parent_->get_kd(); - this->publish_state(value); - return; + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH: + value = this->parent_->get_threshold_high(); + break; + case PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW: + value = this->parent_->get_threshold_low(); + break; + case PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER: + value = this->parent_->get_kp_multiplier(); + break; + case PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER: + value = this->parent_->get_ki_multiplier(); + break; + case PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER: + value = this->parent_->get_kd_multiplier(); + break; default: value = NAN; break; } - this->publish_state(value * 100.0f); + this->publish_state(value); } void PIDClimateSensor::dump_config() { LOG_SENSOR("", "PID Climate Sensor", this); } diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index b62d5977807..1d208c44f89 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -16,6 +16,11 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KP, PID_SENSOR_TYPE_KI, PID_SENSOR_TYPE_KD, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_HIGH, + PID_SENSOR_TYPE_DEADBAND_THRESHOLD_LOW, + PID_SENSOR_TYPE_KP_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KI_DEADBAND_MULTIPLIER, + PID_SENSOR_TYPE_KD_DEADBAND_MULTIPLIER, }; class PIDClimateSensor final : public sensor::Sensor, public Component { diff --git a/tests/components/pid/__init__.py b/tests/components/pid/__init__.py new file mode 100644 index 00000000000..f8b7080743f --- /dev/null +++ b/tests/components/pid/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["climate", "output", "sensor"] diff --git a/tests/components/pid/common.yaml b/tests/components/pid/common.yaml index 320e5f775fe..c78ad8fc1ef 100644 --- a/tests/components/pid/common.yaml +++ b/tests/components/pid/common.yaml @@ -12,6 +12,15 @@ esphome: kp: 0.0 ki: 0.0 kd: 0.0 + - climate.pid.set_deadband_control_parameters_multipliers: + id: pid_climate + kp_multiplier: 0.0 + ki_multiplier: 0.0 + kd_multiplier: 0.0 + - climate.pid.set_deadband_threshold_parameters: + id: pid_climate + threshold_high: 0.5°C + threshold_low: !lambda return -0.5f; - climate.pid.reset_integral_term: pid_climate output: @@ -30,6 +39,26 @@ sensor: } return 0.0; update_interval: 60s + - platform: pid + id: pid_kp_multiplier + climate_id: pid_climate + type: KP_DEADBAND_MULTIPLIER + - platform: pid + id: pid_ki_multiplier + climate_id: pid_climate + type: KI_DEADBAND_MULTIPLIER + - platform: pid + id: pid_kd_multiplier + climate_id: pid_climate + type: KD_DEADBAND_MULTIPLIER + - platform: pid + id: pid_threshold_high + climate_id: pid_climate + type: DEADBAND_THRESHOLD_HIGH + - platform: pid + id: pid_threshold_low + climate_id: pid_climate + type: DEADBAND_THRESHOLD_LOW climate: - platform: pid diff --git a/tests/components/pid/test_deadband_thresholds.cpp b/tests/components/pid/test_deadband_thresholds.cpp new file mode 100644 index 00000000000..ba37a16d8d5 --- /dev/null +++ b/tests/components/pid/test_deadband_thresholds.cpp @@ -0,0 +1,41 @@ +#include + +#include "esphome/components/pid/pid_climate.h" + +namespace esphome::pid { + +TEST(PIDClimateDeadbandThresholds, InvalidThresholdsDoNotChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_FALSE(climate.set_deadband_thresholds(2.0f, 1.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -1.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 1.0f); +} + +TEST(PIDClimateDeadbandThresholds, ValidThresholdsChangeController) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(-2.0f, 0.5f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), -2.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.5f); +} + +TEST(PIDClimateDeadbandThresholds, EqualThresholdsDisableDeadband) { + PIDClimate climate; + climate.set_threshold_low(-1.0f); + climate.set_threshold_high(1.0f); + + EXPECT_TRUE(climate.set_deadband_thresholds(0.0f, 0.0f)); + + EXPECT_FLOAT_EQ(climate.get_threshold_low(), 0.0f); + EXPECT_FLOAT_EQ(climate.get_threshold_high(), 0.0f); + EXPECT_FALSE(climate.in_deadband()); +} + +} // namespace esphome::pid From d78d640abd1b009398e7604bbf3d33801b4d826f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 23 Sep 2026 09:33:00 -0700 Subject: [PATCH 359/759] [modbus] Drop a deferred server reply when another frame arrives first (#19534) --- esphome/components/modbus/modbus.cpp | 7 ++ .../uart_mock_modbus_server_injected.yaml | 68 +++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 62 ++++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 037901a8733..eae418ba3d0 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -203,6 +203,12 @@ void ModbusClientHub::parse_modbus_frames() { void ModbusServerHub::parse_modbus_frames() { while (!this->rx_buffer_.empty()) { + if (this->deferred_payload_len_ != 0) { + // Another frame arrived before the deferred reply went out, so the client has moved on. + this->cancel_timeout("deferred_send"); + ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]); + this->deferred_payload_len_ = 0; + } size_t size = this->rx_buffer_.size(); ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); bool retry_as_client = false; @@ -1213,6 +1219,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); + this->deferred_payload_len_ = 0; if (!this->send_frame_(frame)) { ESP_LOGE(TAG, "Deferred server reply dropped: transmission still blocked"); } diff --git a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml index 2cd1c610f19..8b2113ccd9e 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_injected.yaml @@ -26,6 +26,20 @@ uart_mock: rx_timeout: 2 auto_start: false debug: + # Each burst-case reply from device 1 (single-register FC 0x03, told apart by its register + # value) fires its own sensor when it reaches the wire. One sensor per reply, because the API + # merges updates to the same entity that land within its batching window. + on_tx: + - then: + - lambda: |- + if (data.size() != 7 || data[0] != 0x01 || data[1] != 0x03 || data[3] != 0x00) + return; + switch (data[4]) { + case 0xA1: id(burst_tx_a).publish_state(1); break; + case 0xB2: id(burst_tx_b).publish_state(1); break; + case 0xC3: id(burst_tx_before_peer).publish_state(1); break; + case 0xD4: id(burst_tx_probe).publish_state(1); break; + } injections: - delay: 100ms inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) @@ -52,6 +66,21 @@ uart_mock: - delay: 100ms inject_rx: [0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55] + # Two reads of device 1 (regs 0x0B then 0x0C) in one injection so both land in the rx buffer + # together. The reply to 0x0B is deferred because 0x0C is still queued behind it, and must be + # dropped once 0x0C is parsed: only the reply to 0x0C may reach the wire (burst_read_a/b). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0B, 0x00, 0x01, 0xF5, 0xC8, + 0x01, 0x03, 0x00, 0x0C, 0x00, 0x01, 0x44, 0x09] + # Read of device 1 (reg 0x0D) followed in the same injection by a read of device 2. The client + # has moved on to another device, so the deferred reply to 0x0D must never be sent + # (burst_read_before_peer). + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0D, 0x00, 0x01, 0x15, 0xC9, + 0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] + # Plain read of device 1 (reg 0x0E) whose reply on the wire marks the burst cases as settled. + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x0E, 0x00, 0x01, 0xE5, 0xC9] globals: - id: stored_1 @@ -110,6 +139,24 @@ modbus_server: read_lambda: |- id(read_after_peer_timeout).publish_state(1); return 1; + - address: 0x0B + value_type: U_WORD + read_lambda: |- + id(burst_read_a).publish_state(1); + return 0xA1; + - address: 0x0C + value_type: U_WORD + read_lambda: |- + id(burst_read_b).publish_state(1); + return 0xB2; + - address: 0x0D + value_type: U_WORD + read_lambda: |- + id(burst_read_before_peer).publish_state(1); + return 0xC3; + - address: 0x0E + value_type: U_WORD + read_lambda: return 0xD4; sensor: - platform: template @@ -136,6 +183,27 @@ sensor: - platform: template name: "rw_read_3" id: rw_read_3 + - platform: template + name: "burst_read_a" + id: burst_read_a + - platform: template + name: "burst_read_b" + id: burst_read_b + - platform: template + name: "burst_read_before_peer" + id: burst_read_before_peer + - platform: template + name: "burst_tx_a" + id: burst_tx_a + - platform: template + name: "burst_tx_b" + id: burst_tx_b + - platform: template + name: "burst_tx_before_peer" + id: burst_tx_before_peer + - platform: template + name: "burst_tx_probe" + id: burst_tx_probe button: - platform: template diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 36aa9a9668c..1b877f5948f 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -260,11 +260,71 @@ async def test_uart_mock_modbus_server_read_write( api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) - # The FC 0x17 injections fire last, behind four earlier 100ms delays + # The FC 0x17 injections fire behind four earlier 100ms delays await tracker.await_all(futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_server_injected") +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_burst( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a server reply deferred behind a queued frame is dropped. + + Two requests are injected as one chunk so both sit in the rx buffer at + once. The reply to the first is deferred because the second is still + queued, and must be discarded once the second frame is parsed: + * device 1 reg 0x0B then device 1 reg 0x0C -- only the 0x0C reply is sent; + * device 1 reg 0x0D then a device 2 request -- nothing is sent. + The fixture's on_tx hook fires a sensor per burst reply that reaches the + wire, and a final plain read marks both cases settled once its reply is + seen. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + [ + "burst_read_a", + "burst_read_b", + "burst_read_before_peer", + "burst_tx_a", + "burst_tx_b", + "burst_tx_before_peer", + "burst_tx_probe", + ] + ) + futures = tracker.expect_all( + { + "burst_read_a": 1, + "burst_read_b": 1, + "burst_read_before_peer": 1, + "burst_tx_b": 1, + "burst_tx_probe": 1, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + # Every request is parsed and served by its read_lambda regardless of + # whether its reply reaches the wire. + await tracker.await_all(futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + assert not tracker.sensor_states["burst_tx_a"], ( + "reply to reg 0x0B must be dropped, a later request was queued behind it" + ) + assert not tracker.sensor_states["burst_tx_before_peer"], ( + "reply to reg 0x0D must be dropped, the client moved on to device 2" + ) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_read_write_invalid( yaml_config: str, From 70f8ec73c9db212069d912252653b377805792b9 Mon Sep 17 00:00:00 2001 From: dalklein <49771139+dalklein@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:40:01 -0400 Subject: [PATCH 360/759] [modbus] Log the discarded bytes, not just how many (#19428) Co-authored-by: Claude Opus 5 --- esphome/components/modbus/modbus.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index eae418ba3d0..0cf57ef7aa5 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1241,6 +1241,11 @@ void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_t if (warn) { ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; + ESP_LOGV(TAG, " discarded: %s%s", format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), bytes), + bytes > MODBUS_MAX_LOG_BYTES ? LOG_STR_LITERAL(" ...") : LOG_STR_LITERAL("")); +#endif } else { ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "us after last send", bytes, LOG_STR_ARG(reason), micros() - this->last_send_); From ce6a66959a371f8eb26b81b867cbe270c43c7fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:41:47 +0100 Subject: [PATCH 361/759] [sprinkler] Use register_apply_action for the setter actions (#19471) --- esphome/components/sprinkler/__init__.py | 72 ++++------------------- esphome/components/sprinkler/automation.h | 68 --------------------- tests/components/sprinkler/common.yaml | 12 ++++ 3 files changed, 24 insertions(+), 128 deletions(-) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index efa5b0bf150..21863e26217 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -66,14 +66,9 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -SetDividerAction = sprinkler_ns.class_("SetDividerAction", automation.Action) -SetMultiplierAction = sprinkler_ns.class_("SetMultiplierAction", automation.Action) -QueueValveAction = sprinkler_ns.class_("QueueValveAction", automation.Action) ClearQueuedValvesAction = sprinkler_ns.class_( "ClearQueuedValvesAction", automation.Action ) -SetRepeatAction = sprinkler_ns.class_("SetRepeatAction", automation.Action) -SetRunDurationAction = sprinkler_ns.class_("SetRunDurationAction", automation.Action) StartFromQueueAction = sprinkler_ns.class_("StartFromQueueAction", automation.Action) StartFullCycleAction = sprinkler_ns.class_("StartFullCycleAction", automation.Action) StartSingleValveAction = sprinkler_ns.class_( @@ -418,80 +413,37 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +_VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) + +automation.register_apply_action( "sprinkler.set_divider", - SetDividerAction, SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), ) -async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DIVIDER], args, cg.uint32) - cg.add(var.set_divider(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_multiplier", - SetMultiplierAction, SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), ) -async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MULTIPLIER], args, cg.float_) - cg.add(var.set_multiplier(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.queue_valve", - QueueValveAction, SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - synchronous=True, + automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), ) -async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_repeat", - SetRepeatAction, SPRINKLER_ACTION_REPEAT_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), ) -async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.uint32) - cg.add(var.set_repeat(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "sprinkler.set_valve_run_duration", - SetRunDurationAction, SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - synchronous=True, + automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), ) -async def sprinkler_set_valve_run_duration_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALVE_NUMBER], args, cg.size_t) - cg.add(var.set_valve_number(template_)) - template_ = await cg.templatable(config[CONF_RUN_DURATION], args, cg.uint32) - cg.add(var.set_valve_run_duration(template_)) - return var @automation.register_action( diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index beeec96b98a..14d9bd20ec1 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,46 +6,6 @@ namespace esphome::sprinkler { -template class SetDividerAction final : public Action { - public: - explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, divider) - - void play(const Ts &...x) override { this->sprinkler_->set_divider(this->divider_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetMultiplierAction final : public Action { - public: - explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(float, multiplier) - - void play(const Ts &...x) override { this->sprinkler_->set_multiplier(this->multiplier_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class QueueValveAction final : public Action { - public: - explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->queue_valve(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,34 +16,6 @@ template class ClearQueuedValvesAction final : public Action class SetRepeatAction final : public Action { - public: - explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, repeat) - - void play(const Ts &...x) override { this->sprinkler_->set_repeat(this->repeat_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRunDurationAction final : public Action { - public: - explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->set_valve_run_duration(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/tests/components/sprinkler/common.yaml b/tests/components/sprinkler/common.yaml index dbe109f5244..890cf9fd884 100644 --- a/tests/components/sprinkler/common.yaml +++ b/tests/components/sprinkler/common.yaml @@ -31,6 +31,18 @@ esphome: id: yard_sprinkler_ctrlr valve_number: 0 run_duration: 600s + # Templated values, including both keys of the two-key calls + - sprinkler.set_multiplier: + id: yard_sprinkler_ctrlr + multiplier: !lambda return 1.5f; + - sprinkler.queue_valve: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 300; + - sprinkler.set_valve_run_duration: + id: yard_sprinkler_ctrlr + valve_number: !lambda return 1; + run_duration: !lambda return 120; switch: - platform: template From 487ef61d0afa33f16d07dd910cf004524e4570c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:47:31 +0100 Subject: [PATCH 362/759] [nextion] Use register_apply_action for set_brightness and the publish actions (#19529) --- esphome/components/nextion/automation.h | 96 ------------------- .../nextion/binary_sensor/__init__.py | 30 ++---- esphome/components/nextion/display.py | 19 +--- esphome/components/nextion/sensor/__init__.py | 30 ++---- esphome/components/nextion/switch/__init__.py | 30 ++---- .../nextion/text_sensor/__init__.py | 30 ++---- tests/components/nextion/common.yaml | 1 + 7 files changed, 40 insertions(+), 196 deletions(-) delete mode 100644 esphome/components/nextion/automation.h diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h deleted file mode 100644 index 0226c65be6c..00000000000 --- a/esphome/components/nextion/automation.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/string_ref.h" - -#include "nextion.h" - -namespace esphome::nextion { - -template class NextionSetBrightnessAction final : public Action { - public: - explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, brightness) - - void play(const Ts &...x) override { - this->component_->set_brightness(this->brightness_.value(x...)); - this->component_->set_backlight_brightness(this->brightness_.value(x...)); - } - - void set_brightness(std::function brightness) { this->brightness_ = brightness; } - - protected: - Nextion *component_; -}; - -template class NextionPublishFloatAction final : public Action { - public: - explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(float, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishTextAction final : public Action { - public: - explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(const char *, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -template class NextionPublishBoolAction final : public Action { - public: - explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} - - TEMPLATABLE_VALUE(bool, state) - TEMPLATABLE_VALUE(bool, publish_state) - TEMPLATABLE_VALUE(bool, send_to_nextion) - - void play(const Ts &...x) override { - this->component_->set_state(this->state_.value(x...), this->publish_state_.value(x...), - this->send_to_nextion_.value(x...)); - } - - void set_state(std::function state) { this->state_ = state; } - void set_publish_state(std::function publish_state) { this->publish_state_ = publish_state; } - void set_send_to_nextion(std::function send_to_nextion) { - this->send_to_nextion_ = send_to_nextion; - } - - protected: - NextionComponent *component_; -}; - -} // namespace esphome::nextion diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 29f5bdaea78..6c390e1c6e2 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -18,10 +18,6 @@ NextionBinarySensor = nextion_ns.class_( "NextionBinarySensor", binary_sensor.BinarySensor, cg.PollingComponent ) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( binary_sensor.binary_sensor_schema(NextionBinarySensor) .extend( @@ -57,9 +53,8 @@ async def to_code(config): cg.add(hub.register_binarysensor_component(var)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionBinarySensor), @@ -70,19 +65,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index a5894bdaf7a..73a56e0a42b 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -56,11 +56,6 @@ def AUTO_LOAD() -> list[str]: return base -NextionSetBrightnessAction = nextion_ns.class_( - "NextionSetBrightnessAction", automation.Action -) - - def _deprecated_dump_device_info(value): _LOGGER.warning( "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " @@ -160,9 +155,8 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "display.nextion.set_brightness", - NextionSetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Nextion), @@ -170,16 +164,9 @@ CONFIG_SCHEMA = cv.All( }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_BRIGHTNESS, "set_backlight_brightness", cg.float_), ) -async def nextion_set_brightness_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) - cg.add(var.set_brightness(template_)) - - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 61cb42e62cb..6c93286656a 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -21,10 +21,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSensor = nextion_ns.class_("NextionSensor", sensor.Sensor, cg.PollingComponent) -NextionPublishFloatAction = nextion_ns.class_( - "NextionPublishFloatAction", automation.Action -) - def CheckWaveID(value): value = cv.int_(value) @@ -97,9 +93,8 @@ async def to_code(config): cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) -@automation.register_action( +automation.register_apply_action( "sensor.nextion.publish", - NextionPublishFloatAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSensor), @@ -110,19 +105,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.float_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 29749ecab02..5053c30eb39 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -16,10 +16,6 @@ CODEOWNERS = ["@senexcrenshaw"] NextionSwitch = nextion_ns.class_("NextionSwitch", switch.Switch, cg.PollingComponent) -NextionPublishBoolAction = nextion_ns.class_( - "NextionPublishBoolAction", automation.Action -) - CONFIG_SCHEMA = cv.All( switch.switch_schema(NextionSwitch) .extend(CONFIG_SWITCH_COMPONENT_SCHEMA) @@ -39,9 +35,8 @@ async def to_code(config): await setup_component_core_(var, config, ".val") -@automation.register_action( +automation.register_apply_action( "switch.nextion.publish", - NextionPublishBoolAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionSwitch), @@ -52,19 +47,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.bool_), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/esphome/components/nextion/text_sensor/__init__.py b/esphome/components/nextion/text_sensor/__init__.py index 168a6724979..b8fd0c8fdab 100644 --- a/esphome/components/nextion/text_sensor/__init__.py +++ b/esphome/components/nextion/text_sensor/__init__.py @@ -13,10 +13,6 @@ NextionTextSensor = nextion_ns.class_( "NextionTextSensor", text_sensor.TextSensor, cg.PollingComponent ) -NextionPublishTextAction = nextion_ns.class_( - "NextionPublishTextAction", automation.Action -) - CONFIG_SCHEMA = ( text_sensor.text_sensor_schema(NextionTextSensor) .extend(CONFIG_TEXT_COMPONENT_SCHEMA) @@ -35,9 +31,8 @@ async def to_code(config): await setup_component_core_(var, config, ".txt") -@automation.register_action( +automation.register_apply_action( "text_sensor.nextion.publish", - NextionPublishTextAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(NextionTextSensor), @@ -48,19 +43,12 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyCall( + "set_state({}, {}, {})", + ( + (CONF_STATE, cg.std_string), + (CONF_PUBLISH_STATE, cg.bool_), + (CONF_SEND_TO_NEXTION, cg.bool_), + ), + ), ) -async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_STATE], args, cg.const_char_ptr) - cg.add(var.set_state(template_)) - - template_ = await cg.templatable(config[CONF_PUBLISH_STATE], args, cg.bool_) - cg.add(var.set_publish_state(template_)) - - template_ = await cg.templatable(config[CONF_SEND_TO_NEXTION], args, cg.bool_) - cg.add(var.set_send_to_nextion(template_)) - - return var diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index d79e3ee2ed2..4f4347d89a9 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -3,6 +3,7 @@ esphome: - lambda: 'ESP_LOGD("display","is_connected(): %s", YESNO(id(main_lcd).is_connected()));' - display.nextion.set_brightness: 80% + - display.nextion.set_brightness: !lambda 'return 0.5;' # Binary sensor publish action tests - binary_sensor.nextion.publish: From 4f95ffaa1f1628a8f433fd795672316058a61bc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:49:59 +0100 Subject: [PATCH 363/759] [network] Remove deprecated IPAddress::str() from the host build (#19528) --- esphome/components/network/ip_address.h | 9 --------- 1 file changed, 9 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 28f83cc4fa4..3b0621d1f31 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -147,15 +147,6 @@ struct IPAddress { } return (ntohl(this->ip_addr_.u_addr.ip4.s_addr) & 0xF0000000UL) == 0xE0000000UL; } - // Remove before 2026.8.0 - ESPDEPRECATED( - "str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0", - "2026.2.0") - std::string str() const { - char buf[IP_ADDRESS_BUFFER_SIZE]; - this->str_to(buf); - return buf; - } char *str_to(char *buf) const { if (this->ip_addr_.type == IPADDR_TYPE_V6) { inet_ntop(AF_INET6, &this->ip_addr_.u_addr.ip6, buf, IP_ADDRESS_BUFFER_SIZE); From c33ccc70430b1b76d0f5d2802eaed5936241b2f3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:50:54 +0100 Subject: [PATCH 364/759] [esp32][esp32_hosted] Bump recommended Arduino to 3.3.12 and platform to 55.03.312 and PlatformIO to 6.2.0 (#19417) --- esphome/components/esp32/__init__.py | 15 +++++++++------ esphome/components/esp32_hosted/__init__.py | 2 +- esphome/components/libretiny/__init__.py | 6 ++++++ .../libretiny/scons_dont_inherit.py.script | 16 ++++++++++++++++ esphome/idf_component.yml | 2 +- platformio.ini | 7 ++++--- requirements.txt | 2 +- 7 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 esphome/components/libretiny/scons_dont_inherit.py.script diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e2e40f21bdc..0d409b78575 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -424,6 +424,7 @@ ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( "Hash", "HTTPClient", "HTTPUpdate", + "HTTPUpdateServer", "Insights", "LittleFS", "Matter", @@ -931,14 +932,15 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 11), - "latest": cv.Version(3, 3, 11), - "dev": cv.Version(3, 3, 11), + "recommended": cv.Version(3, 3, 12), + "latest": cv.Version(3, 3, 12), + "dev": cv.Version(3, 3, 12), } ARDUINO_PLATFORM_VERSION_LOOKUP = { cv.Version( 4, 0, 0, "alpha1" ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 12): cv.Version(55, 3, 312), cv.Version(3, 3, 11): cv.Version(55, 3, 311), cv.Version(3, 3, 10): cv.Version(55, 3, 39), cv.Version(3, 3, 9): cv.Version(55, 3, 39), @@ -963,6 +965,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 12): cv.Version(5, 5, 5), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -998,7 +1001,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 311), + cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), @@ -1019,8 +1022,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 311), - "latest": cv.Version(55, 3, 311), + "recommended": cv.Version(55, 3, 312), + "latest": cv.Version(55, 3, 312), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6943efc3cb8..ad01e130eb9 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -313,7 +313,7 @@ async def to_code(config: ConfigType) -> None: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 50dc7877990..8cd7f3e7b62 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -513,6 +513,8 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # Must run before the platform's builder scripts are loaded; see the script. + cg.add_platformio_option("extra_scripts", ["pre:scons_dont_inherit.py"]) cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). # On other families, patch_linker.py routes .sram.text into the right @@ -618,4 +620,8 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_file_if_changed( + script_dir / "scons_dont_inherit.py.script", + CORE.relative_build_path("scons_dont_inherit.py"), + ) copy_ccache_script() diff --git a/esphome/components/libretiny/scons_dont_inherit.py.script b/esphome/components/libretiny/scons_dont_inherit.py.script new file mode 100644 index 00000000000..23da6ff3968 --- /dev/null +++ b/esphome/components/libretiny/scons_dont_inherit.py.script @@ -0,0 +1,16 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import builtins +import functools +import sys + +# SCons 4.11 compiles SConscript files without dont_inherit=True, so its own +# `from __future__ import annotations` leaks into every builder script and +# turns their annotations into strings. That breaks LibreTiny's Library +# dataclass, whose InitVar is then no longer recognised. Runs before the +# platform's builder scripts are loaded. The module comes from sys.modules +# because SCons.Script also exports a function named SConscript. +sconscript = sys.modules["SCons.Script.SConscript"] +if sconscript.__dict__.get("compile") is None: + sconscript.compile = functools.partial(builtins.compile, dont_inherit=True) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index d12a27221b9..65d56495af4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.12 + version: 2.12.13 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/platformio.ini b/platformio.ini index 37504384cbc..102c7973b97 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,9 +141,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.11/esp32-core-3.3.11.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component @@ -178,7 +178,7 @@ extra_scripts = ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz @@ -257,6 +257,7 @@ build_flags = build_unflags = ${common.build_unflags} build_src_flags = -include Arduino.h +extra_scripts = pre:esphome/components/libretiny/scons_dont_inherit.py.script ; This is the common settings for the nRF52 using Zephyr. [common:nrf52-zephyr] diff --git a/requirements.txt b/requirements.txt index fd693533452..eacd7e0970c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ colorama==0.4.6 tzlocal==5.4.4 # from time tzdata>=2026.4 # from time pyserial==3.5 -platformio==6.1.19 +platformio==6.2.0 esptool==5.4.0 click==8.3.3 aioesphomeapi==46.5.0 From 4a7fe83a1793084cd4607354d5bd5ba0c3659ac7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:51:20 +0100 Subject: [PATCH 365/759] [speaker] Use register_apply_action for the speaker actions (#19535) --- esphome/components/speaker/__init__.py | 55 ++++--------------- esphome/components/speaker/automation.h | 35 ------------ .../speaker/media_player/__init__.py | 36 +++++------- .../speaker/media_player/automation.h | 25 --------- .../speaker/common-media_player.yaml | 5 ++ tests/components/speaker/common.yaml | 3 + 6 files changed, 34 insertions(+), 125 deletions(-) delete mode 100644 esphome/components/speaker/media_player/automation.h diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 98b5abe58cb..e62e4ccfd33 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -18,21 +18,6 @@ Speaker = speaker_ns.class_("Speaker") PlayAction = speaker_ns.class_( "PlayAction", automation.Action, cg.Parented.template(Speaker) ) -StopAction = speaker_ns.class_( - "StopAction", automation.Action, cg.Parented.template(Speaker) -) -FinishAction = speaker_ns.class_( - "FinishAction", automation.Action, cg.Parented.template(Speaker) -) -VolumeSetAction = speaker_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOnAction = speaker_ns.class_( - "MuteOnAction", automation.Action, cg.Parented.template(Speaker) -) -MuteOffAction = speaker_ns.class_( - "MuteOffAction", automation.Action, cg.Parented.template(Speaker) -) IsPlayingCondition = speaker_ns.class_("IsPlayingCondition", automation.Condition) @@ -96,12 +81,15 @@ async def speaker_play_action(config, action_id, template_arg, args): return var -automation.register_action( - "speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) -automation.register_action( - "speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -)(speaker_action) +for _name, _call in ( + ("speaker.stop", "stop()"), + ("speaker.finish", "finish()"), + ("speaker.mute_on", "set_mute_state(true)"), + ("speaker.mute_off", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, SPEAKER_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) automation.register_condition( "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA @@ -112,9 +100,8 @@ automation.register_condition( )(speaker_action) -@automation.register_action( +automation.register_apply_action( "speaker.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Speaker), @@ -122,28 +109,8 @@ automation.register_condition( }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def speaker_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var - - -@automation.register_action( - "speaker.mute_off", - MuteOffAction, - SPEAKER_AUTOMATION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True -) -async def speaker_mute_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 443588a04c1..50097ab59a1 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -38,41 +38,6 @@ template class PlayAction final : public Action, public P } data_; }; -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(true); } - - protected: - Speaker *speaker_; -}; - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} - - void play(const Ts &...x) override { this->speaker_->set_mute_state(false); } - - protected: - Speaker *speaker_; -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class FinishAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish(); } -}; - template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index e1808889f4b..78892bff226 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -22,7 +22,6 @@ from esphome.const import ( CONF_BUFFER_SIZE, CONF_FILES, CONF_FORMAT, - CONF_ID, CONF_NUM_CHANNELS, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -63,11 +62,6 @@ AUDIO_PIPELINE_TYPE_ENUM = { "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, } -PlayOnDeviceMediaAction = speaker_ns.class_( - "PlayOnDeviceMediaAction", - automation.Action, - cg.Parented.template(SpeakerMediaPlayer), -) StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) ) @@ -239,9 +233,8 @@ async def to_code(config): audio_file.generate_audio_file_code(file_config) -@automation.register_action( +automation.register_apply_action( "media_player.speaker.play_on_device_media_file", - PlayOnDeviceMediaAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SpeakerMediaPlayer), @@ -251,17 +244,18 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {}, {})", + ( + # The file is a use_id of a global AudioFile pointer; global-scope qualified + # so a trigger arg with the same name cannot shadow it. + ( + CONF_MEDIA_FILE, + audio.AudioFile.operator("ptr"), + lambda _, value: f"::{value}", + ), + (CONF_ANNOUNCEMENT, cg.bool_), + (CONF_ENQUEUE, cg.bool_), + ), + ), ) -async def play_on_device_media_media_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_file = await cg.get_variable(config[CONF_MEDIA_FILE]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - enqueue = await cg.templatable(config[CONF_ENQUEUE], args, cg.bool_) - - template_ = await cg.templatable(media_file, args, audio.AudioFile.operator("ptr")) - cg.add(var.set_audio_file(template_)) - cg.add(var.set_announcement(announcement)) - cg.add(var.set_enqueue(enqueue)) - return var diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h deleted file mode 100644 index f9e21279939..00000000000 --- a/esphome/components/speaker/media_player/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "speaker_media_player.h" - -#ifdef USE_ESP32 - -#include "esphome/components/audio/audio.h" -#include "esphome/core/automation.h" - -namespace esphome::speaker { - -template -class PlayOnDeviceMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) - TEMPLATABLE_VALUE(bool, announcement) - TEMPLATABLE_VALUE(bool, enqueue) - void play(const Ts &...x) override { - this->parent_->play_file(this->audio_file_.value(x...), this->announcement_.value(x...), - this->enqueue_.value(x...)); - } -}; - -} // namespace esphome::speaker - -#endif diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index 3b2212a0caa..7aef904a920 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -30,3 +30,8 @@ script: id: speaker_media_player_id media_file: speaker_test_audio announcement: true + - media_player.speaker.play_on_device_media_file: + id: speaker_media_player_id + media_file: speaker_test_audio + announcement: !lambda return id(speaker_my_number).state > 50; + enqueue: !lambda return id(speaker_my_number).state < 50; diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index 96f459c53f3..e5c9f366504 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -25,6 +25,9 @@ esphome: - speaker.volume_set: id: speaker_id volume: 0.9 + - speaker.volume_set: + id: speaker_id + volume: !lambda return id(speaker_my_number).state / 100.0f; - if: condition: speaker.is_playing: From fa7ccc934e0de9827f932b442bc059d4e4c670d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:52:00 +0100 Subject: [PATCH 366/759] [pulse_meter] Use register_apply_action for pulse_meter.set_total_pulses (#19533) --- esphome/components/pulse_meter/automation.h | 21 --------------------- esphome/components/pulse_meter/sensor.py | 21 +++------------------ tests/components/pulse_meter/common.yaml | 3 +++ 3 files changed, 6 insertions(+), 39 deletions(-) delete mode 100644 esphome/components/pulse_meter/automation.h diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h deleted file mode 100644 index 885922a22ac..00000000000 --- a/esphome/components/pulse_meter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_meter/pulse_meter_sensor.h" - -namespace esphome::pulse_meter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_meter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseMeterSensor *pulse_meter_; -}; - -} // namespace esphome::pulse_meter diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 9bda891efc8..2df1ba96e97 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -19,8 +19,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID, TimePeriodMicroseconds -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriodMicroseconds from esphome.types import ConfigType CODEOWNERS = ["@stevebaxter", "@cstaahl", "@TrentHouliston"] @@ -38,8 +37,6 @@ FILTER_MODES = { "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, } -SetTotalPulsesAction = pulse_meter_ns.class_("SetTotalPulsesAction", automation.Action) - def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: return cv.positive_time_period_microseconds(value) @@ -100,25 +97,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_meter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseMeterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/tests/components/pulse_meter/common.yaml b/tests/components/pulse_meter/common.yaml index a83ec478bb1..8721f2e375f 100644 --- a/tests/components/pulse_meter/common.yaml +++ b/tests/components/pulse_meter/common.yaml @@ -9,5 +9,8 @@ sensor: - pulse_meter.set_total_pulses: id: pulse_meter_sensor value: 12345 + - pulse_meter.set_total_pulses: + id: pulse_meter_sensor + value: !lambda return (uint32_t) x; total: name: Pulse Meter Total From 9d87fcd3b7c9d0ca68bb5771ebd97adb26b49ade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:52:05 +0100 Subject: [PATCH 367/759] [esp8266] Add the native build spec (#18559) --- esphome/build_gen/arduino8266.py | 773 +++++++++++ .../unit_tests/build_gen/test_arduino8266.py | 1210 +++++++++++++++++ 2 files changed, 1983 insertions(+) create mode 100644 esphome/build_gen/arduino8266.py create mode 100644 tests/unit_tests/build_gen/test_arduino8266.py diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 00000000000..7d842308f65 --- /dev/null +++ b/esphome/build_gen/arduino8266.py @@ -0,0 +1,773 @@ +"""Build specification for the native ESP8266 Arduino toolchain. + +Transliterates the PlatformIO build spec for the Arduino ESP8266 framework +(``framework-arduinoespressif8266/tools/platformio-build.py`` plus +``platform-espressif8266/builder/main.py``): the flag sets, defines, and +linker-script generation deliberately match what PlatformIO produces so the +binaries stay near-identical between the two toolchains. The ninja emission +(``write_project``) builds on these pieces. + +The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK +version, MMU layout, exceptions, waveform phase) keep working: they are read +from the build flags with the same precedence as the PlatformIO builder. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import logging +import os +from pathlib import Path +import re +import shlex +import subprocess +from typing import TYPE_CHECKING, NamedTuple + +from esphome.arduino8266.framework import toolchain_tool +from esphome.build_helpers.ninja import shell_token as _shell_token +from esphome.components.esp8266 import build_surgery +from esphome.core import CORE, EsphomeError +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + +_LOGGER = logging.getLogger(__name__) + +# Values that land unquoted on generated command lines are shape-checked +# against these before use. re.ASCII: a Unicode digit or word character +# (Arabic-Indic numerals) would pass \d/\w and defeat the named error +_MMU_VALUE_RE = re.compile(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*", re.ASCII) +_MMU_HEX_VALUE_RE = re.compile(r"0[xX][0-9a-fA-F]+[uUlL]*", re.ASCII) +# Only these land in the preprocessed script's ``len =`` fields, which +# build_surgery's segment parser reads back as hex; the other MMU_* macros +# (MMU_EXTERNAL_HEAP=128) are consumed by mmu_iram.h and may be decimal +_MMU_SEGMENT_SIZE_NAMES = ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE") +_BOARD_NAME_RE = re.compile(r"[\w.-]+", re.ASCII) +_F_CPU_RE = re.compile(r"\d+L?", re.ASCII) +_FLASH_LD_NAME_RE = re.compile(r"[\w.-]+\.ld", re.ASCII) + +# Every supported board ships this clock; board_build.f_cpu overrides +_DEFAULT_F_CPU = "80000000L" + +# The SDK linker-script template and the preprocessed copy the build links +# against; the cache stamp and stderr sidecars derive from the output name +_COMMON_LD_HEADER = "eagle.app.v6.common.ld.h" +_COMMON_LD_NAME = "local.eagle.app.v6.common.ld" +# Testing mode shadows the SDK flash ld with a patched copy under this name +_TESTING_LD_PREFIX = "testing_" + +# The recovery hint for a half-extracted or damaged framework cache +_CLEAN_HINT = "run 'esphome clean-all' and retry" + + +def _sdk_ld_dir(framework: Path) -> Path: + return framework / "tools" / "sdk" / "ld" + + +def _apply_surgery(fn, *args: object) -> str: + """Run one build_surgery edit, naming a failed anchor instead of a + traceback (the surgery module raises bare RuntimeError so its + ``.py.script`` twins stay importable without esphome).""" + try: + return fn(*args) + except RuntimeError as err: + raise EsphomeError(str(err)) from err + + +# From platformio-build.py. Knob suffix -> SDK define; the first entry is +# the default (dicts preserve insertion order). With multiple SDK knobs set +# (a pathological config) ties break by table order, since upstream's +# tie-break depends on define order and is not reproducible here. +_NONOSDK_VERSIONS = { + "SDK22x_190703": "NONOSDK22x_190703", + "SDK221": "NONOSDK221", + "SDK22x_190313": "NONOSDK22x_190313", + "SDK22x_191024": "NONOSDK22x_191024", + "SDK22x_191105": "NONOSDK22x_191105", + "SDK22x_191122": "NONOSDK22x_191122", + "SDK305": "NONOSDK305", +} + + +class _LwipVariant(NamedTuple): + """One lwIP build variant: the defines and the prebuilt library that + was compiled with them.""" + + tcp_mss: int + features: int + ipv6: int + lib: str + + +# Knob define -> variant; first match wins, in insertion order (as in +# platformio-build.py) +_LWIP_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY": _LwipVariant( + 536, 1, 1, "lwip6-536-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 1, "lwip6-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH": _LwipVariant( + 1460, 1, 0, "lwip2-1460-feat" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH": _LwipVariant( + 536, 0, 0, "lwip2-536" + ), + "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH": _LwipVariant( + 1460, 0, 0, "lwip2-1460" + ), +} +# The default is PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY's variant: upstream +# has no branch for that spelling (it is the else), so any listed knob wins +# over it -- sntp emits LOW_MEMORY while esp8266 always emits +# HIGHER_BANDWIDTH_LOW_FLASH, and the latter must win as under PlatformIO +_LWIP_DEFAULT = _LwipVariant(536, 1, 0, "lwip2-536-feat") + +# Knob define -> MMU_* defines; first match wins, in insertion order (as +# in platformio-build.py) +_MMU_VARIANTS = { + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED": ( + "MMU_IRAM_SIZE=0xC000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_HEAP", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ), + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=128", + ), + # Upstream really does cap the 1024K option's heap knob at 256 + # (platformio-build.py's MMU_EXTERNAL_1024K branch); transliterated + # verbatim + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K": ( + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x8000", + "MMU_EXTERNAL_HEAP=256", + ), +} +# From platformio-build.py: the invariant framework defines every TU gets +# (ARDUINO=10805 encodes the IDE compatibility level); the board, flash-mode, +# knob, and MMU defines are composed around them in _defines_flags, in +# upstream's order. +_FRAMEWORK_DEFINES = ("__ets__", "ICACHE_FLASH", "_GNU_SOURCE", "ARDUINO=10805") +_ARCH_DEFINES = ("ESP8266", "ARDUINO_ARCH_ESP8266") + +# Upstream reads these from the board manifest (build.mmu_iram_size etc.); +# no supported board sets them, so the platformio-build.py defaults are +# hardcoded here rather than drift +_MMU_DEFAULT = ("MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000") + +# Upstream's CXXFLAGS (-fno-rtti, the -std level, -f(no-)exceptions) and the +# trailing stdc++/m/c/gcc system libs are composed at emission +# (write_project) from CORE.cpp_standard and _BuildConfig.exceptions. +_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"] +_CFLAGS = [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", +] +_CCFLAGS = [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", +] +# Upstream's -u _scanf_float is deliberately absent: it is re-added from +# KEY_SCANF_FLOAT at emission (the remove_float_scanf extra script's job). +_LINKFLAGS = [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", +] +_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"] +_SYSTEM_LIBS_POST_LWIP = [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", +] + + +@dataclass +class _BuildConfig: + """Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines).""" + + nonosdk: str + lwip_lib: str + exceptions: bool + vtables: str + fp_in_irom: bool + knob_defines: list[str] + mmu_defines: list[str] + + +def _lexed_build_flags() -> list[str]: + """Shell-lex ``CORE.build_flags`` as PlatformIO's ``ParseFlags`` does, + sorted so duplicate defines resolve deterministically. + + Lex once per build; consumers share the tokens. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + return lex_build_flags(sorted(CORE.build_flags), "esphome") + + +def _flag_defines(unflags: set[str], tokens: list[str]) -> dict[str, str]: + """Map define name -> full ``NAME[=VALUE]`` for every -D build flag. + + ``tokens`` comes from one ``_lexed_build_flags()`` call shared with + ``_project_flags``, which already warned about and dropped any bare "-D". + """ + defines: dict[str, str] = {} + for tok in tokens: + # An unflagged knob must not drive lwIP/SDK/MMU selection while + # being absent from the compile line + if tok in unflags: + continue + if tok.startswith("-D"): + body = tok[2:] + defines[body.split("=", 1)[0]] = body + return defines + + +def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: + nonosdk = next( + ( + define + for name, define in _NONOSDK_VERSIONS.items() + if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines + ), + next(iter(_NONOSDK_VERSIONS.values())), + ) + # Same compile-line/linked-artifact split as the lwIP knobs below: a + # raw NONOSDK* would define a second SDK macro while the link still + # resolves against the knob's libraries + if raw_sdk := sorted(n for n in defines if n.startswith("NONOSDK")): + raise EsphomeError( + f"{', '.join(raw_sdk)} are set by the " + "PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK* knobs; drop the raw " + "build flags" + ) + + lwip = next( + (variant for knob, variant in _LWIP_VARIANTS.items() if knob in defines), + _LWIP_DEFAULT, + ) + + # The lwIP triple selects a prebuilt library; a raw override would win + # the compile line (user tokens come last here) while the link still + # pulls the library built for the knob's values + if owned := sorted( + n for n in ("TCP_MSS", "LWIP_FEATURES", "LWIP_IPV6") if n in defines + ): + raise EsphomeError( + f"{', '.join(owned)} are set by the PIO_FRAMEWORK_ARDUINO_LWIP2_* " + "knobs; drop the raw build flags" + ) + knob_defines = [ + f"{nonosdk}=1", + f"TCP_MSS={lwip.tcp_mss}", + f"LWIP_FEATURES={lwip.features}", + f"LWIP_IPV6={lwip.ipv6}", + ] + if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines: + knob_defines.append("WAVEFORM_LOCKED_PHASE=1") + + # Sorted so the pick is deterministic: the dict is built from a set of + # build flags, whose iteration order varies between processes. + vtables_knobs = sorted(name for name in defines if name.startswith("VTABLES_IN_")) + known_vtables = {"VTABLES_IN_FLASH", "VTABLES_IN_DRAM", "VTABLES_IN_IRAM"} + # A typo'd or conflicting knob would otherwise fail obscurely in the + # SDK header's #error + if unknown := [k for k in vtables_knobs if k not in known_vtables]: + raise EsphomeError(f"Unknown VTABLES_IN_* define(s): {', '.join(unknown)}") + # A body (e.g. VTABLES_IN_FLASH=0) would split the compile line from the + # linker script, which always defines the bare name + if valued := [defines[k] for k in vtables_knobs if defines[k] not in (k, f"{k}=1")]: + raise EsphomeError(f"VTABLES_IN_* defines take no value: {', '.join(valued)}") + if len(vtables_knobs) > 1: + raise EsphomeError( + f"Conflicting VTABLES_IN_* defines: {', '.join(vtables_knobs)}" + ) + vtables = vtables_knobs[0] if vtables_knobs else "VTABLES_IN_FLASH" + + mmu_knob = next((knob for knob in _MMU_VARIANTS if knob in defines), None) + if mmu_knob is not None: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Same compile-line/linker-script split as the no-knob case below + fix = ( + f"drop {mmu_knob} to use the custom sizes" + if "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines + else "drop the raw MMU_* build flags or use " + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + raise EsphomeError(f"{', '.join(raw)} conflict with {mmu_knob}; {fix}") + mmu = list(_MMU_VARIANTS[mmu_knob]) + elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines: + if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines: + raise EsphomeError( + "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and " + "MMU_ICACHE_SIZE build flags" + ) + for name in _MMU_SEGMENT_SIZE_NAMES: + # A bare -Dname would preprocess to len = 1 and fail far away + if "=" not in defines[name]: + raise EsphomeError( + f"{name} must be a hex literal (e.g. 0x8000), got (no value)" + ) + for name, body in defines.items(): + if not name.startswith("MMU_") or "=" not in body: + # Valueless flags (MMU_IRAM_HEAP) are legitimate switches + continue + # Every valued MMU_* reaches the linker-script preprocessor; a + # bare or non-numeric value would corrupt it and fail far away + # in ld. The two segment sizes must additionally be hex: + # build_surgery's segment parser cannot read decimal back. + value = body.partition("=")[2] + rule = ( + _MMU_HEX_VALUE_RE if name in _MMU_SEGMENT_SIZE_NAMES else _MMU_VALUE_RE + ) + if not rule.fullmatch(value): + shape = ( + "a hex literal (e.g. 0x8000)" + if name in _MMU_SEGMENT_SIZE_NAMES + else "a numeric literal" + ) + raise EsphomeError( + f"{name} must be {shape}, got {value or '(no value)'}" + ) + # Sorted so build.ninja and the linker-script stamp stay + # byte-stable across runs (the flag set has no deterministic + # iteration order). + mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_")) + else: + if raw := sorted(n for n in defines if n.startswith("MMU_")): + # Unlike PlatformIO (whose defaults win the compile line), user + # MMU_* here would win the compile but not the linker script; + # refuse them all, like the knob branch above. + raise EsphomeError( + f"Raw {', '.join(raw)} build flags require " + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" + ) + mmu = list(_MMU_DEFAULT) + + return _BuildConfig( + nonosdk=nonosdk, + lwip_lib=lwip.lib, + exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines, + vtables=vtables, + fp_in_irom="FP_IN_IROM" in defines, + knob_defines=knob_defines, + mmu_defines=mmu, + ) + + +def _pio_option(key: str, default: str) -> str: + """A platformio_options value the native build honors (str-normalized). + + core/config.py routes these into ``CORE.platformio_options`` under the + arduino toolchain and already collapses a repeated option to its last + value (like a later platformio.ini line), so a scalar always arrives. + """ + value = CORE.platformio_options.get(key) + if value is None: + return default + value = str(value).strip() + if not value: + raise EsphomeError(f"platformio_options {key} is empty") + return value + + +def _defines_flags( + config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...] +) -> list[str]: + r"""The framework/board -D tokens for the compile line. + + The returned tokens already carry shell-level escaping (the board + defines embed ``\"``), so they must be emitted unquoted; wrapping + them in ``_shell_token`` would deliver literal backslashes to gcc. + ``flash_mode`` also lands unquoted: callers pass it pre-validated + against ``BUILD_FLASH_MODES`` (cv.one_of at config time, the + ``_FLASH_MODES`` check at the emission half's read site). + """ + if not _BOARD_NAME_RE.fullmatch(board): + # The name lands unquoted in two -D bodies; reject it by name + # instead of corrupting the compile line + raise EsphomeError(f"Invalid board name {board!r}") + # Every supported board ships 80 MHz; board_build.f_cpu overrides + f_cpu = _pio_option("board_build.f_cpu", _DEFAULT_F_CPU) + if not _F_CPU_RE.fullmatch(f_cpu): + # The value lands unquoted on the compile line; reject by name + # instead of corrupting it + raise EsphomeError(f"Invalid board_build.f_cpu value {f_cpu!r}") + return [ + f"-D{d}" + for d in ( + f"F_CPU={f_cpu}", + *_FRAMEWORK_DEFINES, + f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"', + f'ARDUINO_BOARD_ID=\\"{board}\\"', + f"FLASHMODE_{flash_mode.upper()}", + "LWIP_OPEN_SRC", + *config.knob_defines, + config.vtables, + # User-supplied bodies re-quote like every other user token + # (a no-op for real MMU values) + *(_shell_token(d) for d in config.mmu_defines), + *_ARCH_DEFINES, + *board_defines, + ) + ] + + +def _unflag_tokens() -> set[str]: + """``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches.""" + # Lexed like _lexed_build_flags reads build_flags, so "-D FOO" removes + # -DFOO in both spellings (PlatformIO's ProcessUnFlags parses the same + # way) and no bare half can collaterally drop an unrelated token + return set(lex_build_flags(list(CORE.build_unflags), "esphome build_unflags")) + + +def _project_flags( + unflags: set[str], tokens: list[str] +) -> tuple[list[str], list[str], list[Path], list[str]]: + """Split the ESPHome build flags into compile, linker, -L, and -l lists. + + Plain-form linker flags (``_PLAIN_LINKER_FLAGS``/``_PLAIN_LINKER_PREFIXES``) + raise: they would be inert on the ``-c`` compile line. + ``compile_flags``/``link_flags`` come back shell-quoted; + ``lib_dirs``/``libs`` are raw, quote at emission. + """ + compile_flags: list[str] = [] + link_flags: list[str] = [] + lib_dirs: list[Path] = [] + libs: list[str] = [] + for tok in tokens: + if tok in unflags: + continue + # _lexed_build_flags warned about and dropped any bare -I/-D/-L/-l + if tok.startswith("-Wl,"): + link_flags.append(_shell_token(tok)) + elif tok.startswith("-L"): + lib_dirs.append(Path(tok[2:])) + elif tok.startswith("-l"): + libs.append(tok[2:]) + else: + if tok.startswith(_PLAIN_DRIVER_LINK_PREFIXES): + # Driver options with no -Wl, spelling; ld would reject them + raise EsphomeError( + f"Link flag {tok} in build_flags is not supported by the " + "native toolchain" + ) + if tok in _PLAIN_LINKER_FLAGS or tok.startswith(_PLAIN_LINKER_PREFIXES): + raise EsphomeError( + f"Linker flag {tok} in build_flags is not routed to the " + "link line; use the -Wl, form" + ) + if tok.startswith("-") and not tok.startswith(_COMPILE_FLAG_PREFIXES): + # The linker deny lists are not exhaustive; an unlisted + # link-only spelling would be inert on the -c compile line, + # so at least surface the odd shape + _LOGGER.warning( + "Build flag %s is not a recognized compile-flag shape; " + "it is passed to the compile line only", + tok, + ) + compile_flags.append(_shell_token(tok)) + return compile_flags, link_flags, lib_dirs, libs + + +# Recognized compile-flag shapes: the allow-list feeding the fall-through +# warning in _project_flags (an unlisted link-only spelling still reaches +# the compile line, but not silently) +_COMPILE_FLAG_PREFIXES = ( + "-D", + "-I", + "-U", + "-W", + "-f", + "-m", + "-O", + "-g", + "-std=", + "-include", +) +# Plain-form linker flags rejected by _project_flags: inert on a -c compile +# line, so the firmware would silently lack the requested link behavior. +# Best-effort, not exhaustive; see _COMPILE_FLAG_PREFIXES above. +_PLAIN_LINKER_FLAGS = ( + "-u", + "-e", + "-s", + "-static", + "-nostartfiles", + "-nodefaultlibs", + "-nostdlib", + "-rdynamic", +) +_PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") +# Driver options, not ld options: -Wl, has no equivalent for these +_PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") + + +def _stat_sig(path: Path) -> str: + """Size and mtime cache-stamp signature for one input file. + + Absent stays deterministic ("missing": the spawn names it); unreadable + forces a cache miss every run rather than pinning the stamp to a + constant that can never notice a later edit. + """ + try: + st = path.stat() + return f"{st.st_size}:{st.st_mtime_ns}" + except FileNotFoundError: + return "missing" + except OSError as err: + _LOGGER.warning( + "Could not stat %s (%s); regenerating the linker script every " + "build. Run 'esphome clean-all' to reinstall the framework.", + path, + err, + ) + return f"unreadable:{os.urandom(8).hex()}" + + +def _write_note(path: Path, text: str, *, warn: bool = False) -> bool: + """Best-effort bookkeeping write; a failure never fails the build. + + ``warn`` marks notes whose loss drops a diagnostic on later cached + builds; a lost stamp only costs a cache miss and stays at debug. + Returns whether the write persisted, so a lost warn note can veto + the cache stamp and keep the diagnostic re-derivable. + """ + try: + path.write_text(text, encoding="utf-8") + except OSError as err: + log = _LOGGER.warning if warn else _LOGGER.debug + log("Could not write %s: %s", path, err) + return False + return True + + +def generate_ld_scripts( + paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str +) -> None: + """Generate the common linker script (and testing-mode flash ld copy). + + Runs the same preprocessor invocation as the PlatformIO builder over + ``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi + rate-table DRAM relocation, and enlarged memory segments in testing mode. + """ + if not _FLASH_LD_NAME_RE.fullmatch(flash_ld_name): + # Joined under the SDK and build ld dirs; never a path or traversal + raise EsphomeError(f"Invalid flash linker script name {flash_ld_name!r}") + framework = paths.framework + gcc = toolchain_tool(paths.toolchain, "gcc") + ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld") + mkdir_p(ld_dir) + + cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"] + cmd += [f"-D{d}" for d in config.mmu_defines] + if config.fp_in_irom: + cmd.append("-DFP_IN_IROM") + header = _sdk_ld_dir(framework) / _COMMON_LD_HEADER + cmd += [str(header), "-o", "-"] + + # The inputs are the command line (defines + framework version, which is + # baked into the paths) plus testing mode; skip the preprocessor spawn on + # incremental builds when nothing changed. + output = ld_dir / _COMMON_LD_NAME + stamp = ld_dir / f".{_COMMON_LD_NAME}.stamp" + # Stamp includes the header/gcc stat (catches in-place re-extraction) + # and the surgery fingerprint (a build_surgery edit invalidates old + # build dirs) + stamp_content = ( + # shlex.join: a spaced path stays one quoted element, so two + # different cmd lists can never collide to the same stamp string + shlex.join(cmd) + + f" testing={CORE.testing_mode}" + + f" header={_stat_sig(header)}" + + f" gcc={_stat_sig(gcc)}" + + f" {build_surgery.surgery_fingerprint()}" + ) + + stderr_note = ld_dir / f".{_COMMON_LD_NAME}.stderr" + + def _note_digest() -> str: + # The note is an output like the script itself; folding its state + # into the stamp makes an externally removed or edited note a cache + # miss that re-runs -E and re-derives the diagnostic + if not stderr_note.is_file(): + return "none" + return hashlib.sha256(stderr_note.read_bytes()).hexdigest() + + def _cached_ld_is_valid() -> bool: + # Any damaged cache regenerates; never abort the build over it. The + # stamp records the sha256 of the content written, so an externally + # edited script regenerates too. + try: + if not (output.is_file() and stamp.is_file()): + return False + rest, sep, digest = stamp.read_text(encoding="utf-8").rpartition( + " content=" + ) + inputs, note_sep, note_digest = rest.rpartition(" note=") + return ( + bool(sep) + and bool(note_sep) + and inputs == stamp_content + and note_digest == _note_digest() + and hashlib.sha256(output.read_bytes()).hexdigest() == digest + ) + except (OSError, UnicodeDecodeError): + return False + + if not _cached_ld_is_valid(): + try: + result = subprocess.run( + cmd, + capture_output=True, + check=False, + close_fds=False, + ) + except OSError as err: + # A half-extracted or half-deleted toolchain cache reaches here + raise EsphomeError(f"Could not run {gcc}: {err}; {_CLEAN_HINT}") from err + # Localized gcc diagnostics on a non-UTF-8 console must degrade, + # not UnicodeDecodeError the build; the script itself (below) is + # decoded strictly instead, so a mangled byte can never be cached + stderr_text = result.stderr.decode("utf-8", errors="replace") + if result.returncode != 0: + raise EsphomeError(f"Generating the linker script failed:\n{stderr_text}") + note_persisted = True + if stderr_text.strip(): + # Preprocessor warnings on the success path must reach the user + # on this and every later cached build (see the re-emit below) + _LOGGER.warning("Linker-script preprocessor: %s", stderr_text.strip()) + note_persisted = _write_note(stderr_note, stderr_text.strip(), warn=True) + else: + try: + stderr_note.unlink(missing_ok=True) + except OSError as err: + # A kept stale note would re-emit an obsolete diagnostic on + # every cache hit; skip the stamp so -E re-derives the truth + _LOGGER.warning( + "Could not remove %s (%s); the linker script will " + "regenerate every build until it is removable; %s", + stderr_note, + err, + _CLEAN_HINT, + ) + note_persisted = False + try: + stdout_text = result.stdout.decode("utf-8") + except UnicodeDecodeError as err: + # -CC keeps header comments verbatim; a non-UTF-8 byte replaced + # with U+FFFD would be cached as valid for the build dir's life + raise EsphomeError( + f"Preprocessed linker script from {header} is not UTF-8: " + f"{err}; {_CLEAN_HINT}" + ) from err + if "SECTIONS" not in stdout_text: + # A degenerate zero-exit run must not be stamped as a good cache + raise EsphomeError( + f"Generated linker script is missing its SECTIONS block; {_CLEAN_HINT}" + ) + content = _apply_surgery(build_surgery.relocate_ratetable, stdout_text) + if CORE.testing_mode: + content = _apply_surgery( + build_surgery.apply_testing_memory_patches, content, ("iram1_0_seg",) + ) + write_file_if_changed(output, content) + if note_persisted: + # An unstamped cache re-runs -E next build, re-deriving the + # diagnostic the lost note would have re-emitted + _write_note( + stamp, + f"{stamp_content} note={_note_digest()} " + f"content={hashlib.sha256(content.encode('utf-8')).hexdigest()}", + ) + elif stderr_note.is_file(): + # Re-emit cached preprocessor warnings on cache hits + try: + _LOGGER.warning( + "Linker-script preprocessor: %s", + stderr_note.read_text(encoding="utf-8"), + ) + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "A cached linker-script preprocessor diagnostic exists at %s " + "but could not be read: %s", + stderr_note, + err, + ) + + if CORE.testing_mode: + _generate_testing_flash_ld(framework, ld_dir, flash_ld_name) + + +def _generate_testing_flash_ld( + framework: Path, ld_dir: Path, flash_ld_name: str +) -> None: + """A patched copy of the flash ld in the build dir; resolved through the + same -L path as the SDK original it shadows.""" + flash_ld = _sdk_ld_dir(framework) / flash_ld_name + try: + flash_ld_text = flash_ld.read_text(encoding="utf-8") + except OSError as err: + # Same half-extracted-cache hazard as the preprocessor spawn + raise EsphomeError(f"Could not read {flash_ld}: {err}; {_CLEAN_HINT}") from err + patched_flash_ld = _apply_surgery( + build_surgery.apply_testing_memory_patches, + flash_ld_text, + ("dram0_0_seg", "irom0_0_seg"), + ) + write_file_if_changed( + ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld + ) diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py new file mode 100644 index 00000000000..21a81804aba --- /dev/null +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -0,0 +1,1210 @@ +"""Drift tests for the native ESP8266 Arduino build spec. + +These pin the ESPHome side of the transliteration (the knob-define +precedence, the define/flag sets, and the linker-script generation) against +literals audited from the PlatformIO builder +(framework-arduinoespressif8266/tools/platformio-build.py and +platform-espressif8266/builder/main.py). They catch an accidental edit on +this side; an upstream change in a new framework release is caught by the +A/B byte-identical build check on a version bump, not by these tests. +""" + +from __future__ import annotations + +from collections.abc import Generator +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.arduino8266.framework import InstalledPaths, toolchain_tool +from esphome.build_gen import arduino8266 +from esphome.build_gen.arduino8266 import ( + _defines_flags, + _flag_defines, + _resolve_build_config, +) +from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD +from esphome.components.esp8266.build_surgery import RATETABLE_RULE +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_MODE, + KEY_SCANF_FLOAT, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> Generator[None]: + CORE.name = "test8266" + CORE.build_path = tmp_path + CORE.testing_mode = False + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} + CORE.data[KEY_ESP8266] = { + KEY_BOARD: "nodemcuv2", + KEY_FLASH_MODE: "dout", + KEY_SCANF_FLOAT: False, + } + yield + # CORE.reset() (the suite-wide autouse fixture) does not clear this flag + CORE.testing_mode = False + + +def _set_flags(*flags: str) -> None: + CORE.build_flags = set(flags) + + +def _resolve(*flags: str) -> arduino8266._BuildConfig: + """Set the build flags and resolve the knob config in one step.""" + _set_flags(*flags) + return _resolve_current() + + +def _defines() -> dict[str, str]: + """The -D map for the current build flags.""" + return _flag_defines(set(), arduino8266._lexed_build_flags()) + + +def _resolve_current() -> arduino8266._BuildConfig: + """Resolve whatever flags are already set (must not clear them).""" + return _resolve_build_config(_defines()) + + +def _split_flags() -> tuple[list[str], list[str], list[Path], list[str]]: + """Classify the current build flags the way write_project does.""" + return arduino8266._project_flags( + arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() + ) + + +def _ok_result( + stdout: str | bytes | None = None, stderr: str | bytes = "" +) -> MagicMock: + """A successful preprocessor spawn (defaults to the common ld output). + + Streams are bytes, as the un-decoded subprocess.run delivers them. + """ + stdout = _COMMON_LD_H_OUTPUT if stdout is None else stdout + return MagicMock( + returncode=0, + stdout=stdout.encode() if isinstance(stdout, str) else stdout, + stderr=stderr.encode() if isinstance(stderr, str) else stderr, + ) + + +def test_build_config_defaults() -> None: + + config = _resolve() + assert config.nonosdk == "NONOSDK22x_190703" + assert config.lwip_lib == "lwip2-536-feat" + assert not config.exceptions + assert config.vtables == "VTABLES_IN_FLASH" + assert config.knob_defines == [ + "NONOSDK22x_190703=1", + "TCP_MSS=536", + "LWIP_FEATURES=1", + "LWIP_IPV6=0", + ] + assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"] + + +def test_build_config_esphome_lwip_knob() -> None: + """The lwIP variant ESPHome selects maps to the same defines and library + as the PlatformIO builder.""" + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert config.lwip_lib == "lwip2-1460" + assert "TCP_MSS=1460" in config.knob_defines + assert "LWIP_FEATURES=0" in config.knob_defines + assert "LWIP_IPV6=0" in config.knob_defines + + +def test_build_config_knobs() -> None: + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DVTABLES_IN_DRAM", + ) + config = _resolve_current() + assert config.nonosdk == "NONOSDK305" + assert config.exceptions + assert config.vtables == "VTABLES_IN_DRAM" + assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"] + + +def test_build_config_mmu_custom_requires_sizes() -> None: + + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM") + + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + config = _resolve_current() + # Emitted pre-sorted so build.ninja stays byte-stable across runs + assert config.mmu_defines == [ + "MMU_ICACHE_SIZE=0x4000", + "MMU_IRAM_SIZE=0xC000", + ] + + +def test_defines_match_platformio_builder() -> None: + """The exact define set the PlatformIO builder passes for nodemcuv2/dout.""" + + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + assert _defines_flags( + _resolve_current(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) == [ + "-DF_CPU=80000000L", + "-D__ets__", + "-DICACHE_FLASH", + "-D_GNU_SOURCE", + "-DARDUINO=10805", + '-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"', + '-DARDUINO_BOARD_ID=\\"nodemcuv2\\"', + "-DFLASHMODE_DOUT", + "-DLWIP_OPEN_SRC", + "-DNONOSDK22x_190703=1", + "-DTCP_MSS=1460", + "-DLWIP_FEATURES=0", + "-DLWIP_IPV6=0", + "-DVTABLES_IN_FLASH", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DESP8266", + "-DARDUINO_ARCH_ESP8266", + "-DARDUINO_ESP8266_NODEMCU_ESP12E", + ] + + +def _make_framework(tmp_path: Path) -> InstalledPaths: + framework = tmp_path / "framework" + core = framework / "cores" / "esp8266" + core.mkdir(parents=True) + for name in ( + "core_esp8266_main.cpp", + "Updater.cpp", + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", + "cont.S", + "abi.c", + ): + (core / name).write_text("") + (framework / "variants" / "nodemcu").mkdir(parents=True) + for sub in ("include", "ld", "lwip2/include", "lib"): + (framework / "tools" / "sdk" / sub).mkdir(parents=True) + (framework / "libraries").mkdir() + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + (toolchain / "include").mkdir() + return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) + + +@pytest.mark.parametrize( + ("knob", "lib", "mss", "features", "ipv6"), + [ + ("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1), + ( + "PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH", + "lwip6-1460-feat", + 1460, + 1, + 1, + ), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0), + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0), + # LOW_MEMORY has no upstream branch: it is the default (else) variant + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", "lwip2-536-feat", 536, 1, 0), + ], +) +def test_build_config_lwip_variants( + knob: str, lib: str, mss: int, features: int, ipv6: int +) -> None: + """Every lwIP knob maps to the same defines and library as the PIO builder.""" + + config = _resolve(f"-D{knob}") + assert config.lwip_lib == lib + assert f"TCP_MSS={mss}" in config.knob_defines + assert f"LWIP_FEATURES={features}" in config.knob_defines + assert f"LWIP_IPV6={ipv6}" in config.knob_defines + + +def test_lwip_low_memory_loses_to_listed_knobs() -> None: + """The ordinary SNTP multi-server config: sntp emits LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH, and the listed knob must win + exactly as in platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip2-1460" + + +def test_lwip_ipv6_wins_over_default_knob() -> None: + """The shipping IPv6 config: network emits IPV6_LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH; IPv6 must win exactly as in + platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip6-536-feat" + assert "LWIP_IPV6=1" in config.knob_defines + + +def test_unrecognized_compile_flag_shape_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """A link-only spelling missing from the deny lists still reaches the + compile line, but no longer silently.""" + _set_flags("-shared") + compile_flags, _link_flags, _lib_dirs, _libs = _split_flags() + assert "-shared is not a recognized compile-flag shape" in caplog.text + assert "-shared" in compile_flags + + +@pytest.mark.parametrize( + ("knob", "expected"), + [ + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED", + ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED", + [ + "MMU_IRAM_SIZE=0x8000", + "MMU_ICACHE_SIZE=0x4000", + "MMU_SEC_HEAP_SIZE=0x4000", + "MMU_SEC_HEAP=0x40108000", + ], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"], + ), + ( + "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K", + ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"], + ), + ], +) +def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None: + + _set_flags(f"-D{knob}") + assert _resolve_build_config(_defines()).mmu_defines == expected + + +def test_build_config_waveform_locked_phase() -> None: + + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM") + assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines + assert config.fp_in_irom + + +_COMMON_LD_H_OUTPUT = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000 +} +SECTIONS +{ + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + } >dram0_0_seg :dram0_0_phdr +} +""" + + +def _run_generate_ld_scripts(paths: InstalledPaths) -> Path: + + config = _resolve_current() + arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld") + return CORE.relative_pioenvs_path(CORE.name, "ld") + + +def test_generate_ld_scripts(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + _set_flags("-DFP_IN_IROM") + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + patch.object(arduino8266._LOGGER, "warning") as mock_warn, + ): + ld_dir = _run_generate_ld_scripts(paths) + # A clean preprocessor run must be quiet + mock_warn.assert_not_called() + content = (ld_dir / "local.eagle.app.v6.common.ld").read_text() + assert RATETABLE_RULE in content + cmd = mock_run.call_args[0][0] + assert "-DVTABLES_IN_FLASH" in cmd + assert "-DMMU_IRAM_SIZE=0x8000" in cmd + assert "-DFP_IN_IROM" in cmd + + # Unchanged inputs skip the preprocessor spawn on the next run + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + + # A surgery edit invalidates the stamp via the fingerprint (a stale + # linker script would otherwise persist until an esphome clean) + with ( + patch.object( + arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" + ), + patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, + ): + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_corrupt_cache_regenerates(tmp_path: Path) -> None: + """A truncated cached linker script regenerates even with a fresh stamp.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text("truncated garbage") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert RATETABLE_RULE in output.read_text() + + +def test_generate_ld_scripts_failure(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + result = MagicMock(returncode=1, stderr=b"nope") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="linker script failed"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: + + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + # Real flash ld scripts carry no iram1_0_seg (that lives in the + # generated common ld); the surgery rejects one it was not asked about + "MEMORY\n{\n" + " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" + " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" + "}\n" + ) + CORE.testing_mode = True + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text() + assert "len = 0x2000000" in patched + + +def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( + tmp_path: Path, +) -> None: + """A missing flash ld in testing mode names the file and the fix.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="Could not read .*clean-all"), + ): + _run_generate_ld_scripts(paths) + + +def test_build_config_nonosdk_precedence() -> None: + """With two SDK knobs set (a pathological config), ties break + deterministically by table order.""" + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", + "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221", + ) + assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" + + +def test_project_flags_trailing_bare_linker_flag_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + _set_flags("-l") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert "Ignoring trailing '-l'" in caplog.text + assert not libs + assert not lib_dirs + assert "-l" not in compile_flags + assert "-l" not in link_flags + + +def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: + _set_flags("-L /d -Wl,-Map=m stray") + compile_flags, link_flags, lib_dirs, libs = _split_flags() + assert lib_dirs == [Path("/d")] + assert link_flags == ["-Wl,-Map=m"] + assert "stray" in compile_flags + assert not libs + + +def test_flag_defines_lexes_multi_token_entries() -> None: + """A knob inside a multi-token entry is detected like PlatformIO does.""" + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert defines["FOO"] == "FOO=1" + config = _resolve_build_config(defines) + assert config.lwip_lib == "lwip2-1460" + + +def test_project_flags_lexes_every_entry() -> None: + """A linker flag anywhere in an entry reaches the link line (PIO parity).""" + _set_flags("-DFOO=1 -lbar") + compile_flags, _link, _dirs, libs = _split_flags() + assert libs == ["bar"] + assert "-DFOO=1" in compile_flags + + +def test_project_flags_unflags_match_tokens() -> None: + """build_unflags removes a token embedded in a multi-token entry.""" + _set_flags("-Os -g3") + CORE.build_unflags = {"-Os"} + compile_flags, _link, _dirs, _libs = _split_flags() + assert "-g3" in compile_flags + assert "-Os" not in compile_flags + + +def test_project_flags_requotes_lexed_defines() -> None: + """A quoted spaced value stays one compiler argument after lex/emit.""" + _set_flags('-DGREETING="hello world"') + compile_flags, _link, _dirs, _libs = _split_flags() + # shlex folds the quotes (as PIO's ParseFlags does); _shell_token + # re-quotes the spaced token so the shell passes one argv element + expected = ( + '"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'" + ) + assert compile_flags == [expected] + + +def test_flag_defines_joins_spaced_define() -> None: + """A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it.""" + _set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + defines = _defines() + assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines + assert "" not in defines + + +def test_build_config_custom_mmu_without_knob_raises() -> None: + """Custom MMU sizes without the CUSTOM knob are refused.""" + with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): + _resolve("-DMMU_IRAM_SIZE=0xC000") + + +def test_flag_defines_lexes_quoted_single_tokens() -> None: + """A quoted single-token define reads the same as on the compile line.""" + _set_flags('-DMMU_SEC_HEAP="0x40108000"') + assert _defines()["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000" + + +def test_flag_defines_duplicate_defines_resolve_deterministically() -> None: + """Duplicate conflicting defines pick the same winner every run (sorted + iteration, last writer wins), independent of the set's hash seed.""" + _set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000") + assert _defines()["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000" + + +def test_flag_tables_match_platformio_builder() -> None: + """The transliterated flag lists pinned verbatim, like the define set: + a drift lands as a test failure, not a binary-size regression.""" + assert arduino8266._ASFLAGS == ["-mlongcalls", "-mtext-section-literals"] + assert arduino8266._CFLAGS == [ + "-std=gnu17", + "-Wpointer-arith", + "-Wno-implicit-function-declaration", + "-Wl,-EL", + "-fno-inline-functions", + "-nostdlib", + ] + assert arduino8266._CCFLAGS == [ + "-Os", + "-mlongcalls", + "-mtext-section-literals", + "-falign-functions=4", + "-U__STRICT_ANSI__", + "-ffunction-sections", + "-fdata-sections", + "-Wall", + "-Werror=return-type", + "-free", + "-fipa-pta", + ] + # Pins the deliberate -u _scanf_float omission + assert arduino8266._LINKFLAGS == [ + "-Os", + "-nostdlib", + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u", + "app_entry", + "-u", + "_printf_float", + "-u", + "_DebugExceptionVector", + "-u", + "_DoubleExceptionVector", + "-u", + "_KernelExceptionVector", + "-u", + "_NMIExceptionVector", + "-u", + "_UserExceptionVector", + ] + # Order is load-bearing: upstream's LIBS order resolves symbols correctly + assert arduino8266._SYSTEM_LIBS_PRE_LWIP == ["hal", "phy", "pp", "net80211"] + assert arduino8266._SYSTEM_LIBS_POST_LWIP == [ + "wpa", + "crypto", + "main", + "wps", + "bearssl", + "espnow", + "smartconfig", + "airkiss", + "wpa2", + ] + + +def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: + """A half-deleted toolchain cache fails with an ESPHome error naming the + binary, not a FileNotFoundError traceback.""" + paths = _make_framework(tmp_path) + _set_flags() + with pytest.raises(EsphomeError, match="Could not run"): + _run_generate_ld_scripts(paths) + + +def test_unflag_tokens_join_spaced_entries() -> None: + """Spaced build_unflags entries ("-D FOO") match the joined token.""" + CORE.build_unflags = {"-D FOO", "-l bar"} + tokens = arduino8266._unflag_tokens() + assert tokens == {"-DFOO", "-lbar"} + CORE.build_flags = {"-DFOO -lbar", "-DBAR"} + compile_flags, _link, _dirs, libs = arduino8266._project_flags( + tokens, arduino8266._lexed_build_flags() + ) + assert compile_flags == ["-DBAR"] + assert libs == [] + + +def test_flag_defines_respects_unflags() -> None: + """An unflagged knob must not drive the derived toolchain config.""" + _set_flags("-DVTABLES_IN_DRAM") + defines = _flag_defines({"-DVTABLES_IN_DRAM"}, arduino8266._lexed_build_flags()) + assert "VTABLES_IN_DRAM" not in defines + config = _resolve_build_config(defines) + assert config.vtables == "VTABLES_IN_FLASH" + + +def test_vtables_unknown_raises() -> None: + """An unknown VTABLES_IN_* knob fails by name.""" + with pytest.raises(EsphomeError, match="Unknown VTABLES_IN_.*BANANA"): + _resolve("-DVTABLES_IN_BANANA") + + +def test_vtables_conflicting_raises() -> None: + with pytest.raises(EsphomeError, match="Conflicting VTABLES_IN_"): + _resolve("-DVTABLES_IN_DRAM", "-DVTABLES_IN_IRAM") + + +def test_empty_lib_flags_warned_and_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """A bare -L would silently add the CWD to the search path; the lex + funnel warns and drops it for every consumer.""" + CORE.build_flags = {'-L ""', '-l ""', "-DFOO"} + assert arduino8266._lexed_build_flags() == ["-DFOO"] + assert "Ignoring '-L' with empty argument" in caplog.text + assert "Ignoring '-l' with empty argument" in caplog.text + + +def test_generate_ld_scripts_surfaces_preprocessor_warnings( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Preprocessor stderr on a zero exit reaches the user; degenerate output is refused.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "Linker-script preprocessor: warning: something" in caplog.text + + # New flags invalidate the stamp so the degenerate run regenerates + _set_flags("-DVTABLES_IN_DRAM") + result = _ok_result(stdout="") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="SECTIONS"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_lost_warn_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A warn note that could not persist skips the stamp, so the next build + re-runs -E and re-derives the diagnostic instead of losing it.""" + paths = _make_framework(tmp_path) + _set_flags() + result = _ok_result(stderr="warning: something") + real_write_text = Path.write_text + + def fail_note_writes(self: Path, text: str, encoding: str = "utf-8") -> int: + if self.name.endswith(".stderr"): + raise OSError("read-only build dir") + return real_write_text(self, text, encoding=encoding) + + with ( + patch.object(arduino8266.subprocess, "run", return_value=result) as run1, + patch.object(Path, "write_text", fail_note_writes), + ): + _run_generate_ld_scripts(paths) + run1.assert_called_once() + assert "Could not write" in caplog.text + + # Unstamped: the second build re-runs the preprocessor + with patch.object(arduino8266.subprocess, "run", return_value=result) as run2: + _run_generate_ld_scripts(paths) + run2.assert_called_once() + assert caplog.text.count("Linker-script preprocessor: warning: something") == 2 + + +def test_generate_ld_scripts_unremovable_stale_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stale warn note that cannot be removed skips the stamp, so the + obsolete diagnostic is not re-emitted on cache hits forever.""" + paths = _make_framework(tmp_path) + _set_flags() + warn = _ok_result(stderr="warning: old") + clean = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=warn): + _run_generate_ld_scripts(paths) + + real_unlink = Path.unlink + + def fail_note_unlink(self: Path, missing_ok: bool = False) -> None: + if self.name.endswith(".stderr"): + raise OSError("locked") + real_unlink(self, missing_ok=missing_ok) + + # Flags changed -> regenerate; clean stderr but the stale note is stuck + _set_flags("-DVTABLES_IN_DRAM") + with ( + patch.object(arduino8266.subprocess, "run", return_value=clean), + patch.object(Path, "unlink", fail_note_unlink), + ): + _run_generate_ld_scripts(paths) + # Unstamped: the next build re-runs -E instead of trusting the cache + with patch.object(arduino8266.subprocess, "run", return_value=clean) as run3: + _run_generate_ld_scripts(paths) + run3.assert_called_once() + + +@pytest.mark.parametrize("name", ["MMU_IRAM_SIZE", "MMU_ICACHE_SIZE"]) +def test_mmu_custom_valueless_segment_size_raises(name: str) -> None: + """A bare -Dname would preprocess to len = 1 and fail far away in ld.""" + other = "MMU_ICACHE_SIZE" if name == "MMU_IRAM_SIZE" else "MMU_IRAM_SIZE" + with pytest.raises(EsphomeError, match=f"{name} must be a hex literal"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", f"-D{name}", f"-D{other}=0x8000") + + +@pytest.mark.parametrize( + "flag", ["-fuse-ld=lld", "--specs=nano.specs", "-specs=nano.specs"] +) +def test_driver_link_flags_rejected_without_wl_advice(flag: str) -> None: + """No -Wl, spelling exists for these; the message must not suggest one.""" + CORE.build_flags = {flag, "-DFOO"} + with pytest.raises(EsphomeError, match="not supported by the native toolchain"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_build_config_mmu_knob_with_raw_mmu_flag_raises() -> None: + """A variant knob plus a raw MMU_* define would split the compile line + from the linker script; refuse like the no-knob case.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE conflict with .*CACHE16"): + _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", "-DMMU_IRAM_SIZE=0x4000") + + +def test_build_config_raw_lwip_define_raises() -> None: + """TCP_MSS/LWIP_* belong to the lwIP knobs: a raw value would win the + compile line while the prebuilt library stays the knob's.""" + with pytest.raises(EsphomeError, match="TCP_MSS are set by the .*LWIP2"): + _resolve("-DTCP_MSS=1024") + + +def test_build_config_mmu_defines_do_not_alias_the_table() -> None: + """The resolved list must be a copy; mutating it must not corrupt the + module table for later builds in the same process.""" + config = _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48") + config.mmu_defines.append("MMU_BOGUS") + again = _resolve_current() + assert "MMU_BOGUS" not in again.mmu_defines + assert all(isinstance(v, tuple) for v in arduino8266._MMU_VARIANTS.values()) + + +def test_lexed_build_flags_shared_between_consumers( + caplog: pytest.LogCaptureFixture, +) -> None: + """Lexing once and passing the tokens to both consumers yields the same + result as each lexing itself, with a malformed entry warned once.""" + _set_flags("-DFOO=1 -l", "-Wl,--wrap=x") + tokens = arduino8266._lexed_build_flags() + assert caplog.text.count("Ignoring trailing '-l'") == 1 + assert _flag_defines(set(), tokens) == _flag_defines( + set(), arduino8266._lexed_build_flags() + ) + assert arduino8266._project_flags(set(), tokens) == arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + + +@pytest.mark.parametrize( + "tok", ["-Tcustom.ld", "-Xlinker", "-u", "-e", "-s", "-static", "-nostartfiles"] +) +def test_project_flags_rejects_plain_linker_forms(tok: str) -> None: + """Plain-form linker flags are refused, naming the -Wl, form.""" + _set_flags(tok) + with pytest.raises(EsphomeError, match="use the -Wl, form"): + arduino8266._project_flags(set(), arduino8266._lexed_build_flags()) + + +def test_project_flags_plain_compile_flags_pass() -> None: + _set_flags("-Os") + compile_flags, _l, _d, _libs = arduino8266._project_flags( + set(), arduino8266._lexed_build_flags() + ) + assert "-Os" in compile_flags + + +def test_generate_ld_scripts_header_change_invalidates_stamp( + tmp_path: Path, +) -> None: + """An in-place framework edit at the same path regenerates the script.""" + paths = _make_framework(tmp_path) + header = paths.framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h" + header.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + header.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> None: + """A non-UTF-8 stamp is a damaged cache: regenerate, never abort.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + (ld_dir / ".local.eagle.app.v6.common.ld.stamp").write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_vtables_valued_define_raises() -> None: + """A VTABLES_IN_* body would split the compile line from the linker + script, which always defines the bare name.""" + with pytest.raises(EsphomeError, match="take no value.*VTABLES_IN_FLASH=0"): + _resolve("-DVTABLES_IN_FLASH=0") + + +def test_defines_flags_invalid_board_raises() -> None: + """The board name lands unquoted in two -D bodies; reject it by name.""" + with pytest.raises(EsphomeError, match="Invalid board name"): + _defines_flags(_resolve(), "dout", "evil board", ()) + + +def test_generate_ld_scripts_invalid_flash_ld_name_raises(tmp_path: Path) -> None: + """The script name joins under the SDK and build ld dirs; a traversal + or path is rejected by name.""" + paths = _make_framework(tmp_path) + _set_flags() + config = _resolve() + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.generate_ld_scripts(paths, config, "../evil.ld") + + +def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None: + """The stamp records the content hash, so an externally edited cached + script regenerates instead of linking untrusted content.""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_text(output.read_text() + "\n/* tampered */\n") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + assert "tampered" not in output.read_text() + + +def test_generate_ld_scripts_corrupt_output_is_overwritten(tmp_path: Path) -> None: + """A non-UTF-8 cached script must be overwritten by the regeneration, + not abort it (write_file_if_changed reads the old content).""" + paths = _make_framework(tmp_path) + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + output = ld_dir / "local.eagle.app.v6.common.ld" + output.write_bytes(b"\xff\xfe") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "SECTIONS" in output.read_text(encoding="utf-8") + + +def test_generate_ld_scripts_non_utf8_output_raises(tmp_path: Path) -> None: + """A non-UTF-8 byte in the preprocessed script fails by header name; a + U+FFFD-mangled script must never be cached as valid.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout=b"SECTIONS { }\xff\xfe") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="is not UTF-8"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_note_read_race_still_warns( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A note that turns unreadable between the digest check and the + re-emit read (TOCTOU) warns by path instead of dropping silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + orig = Path.read_text + + def flaky(self, *args, **kwargs): + if self.name.endswith(".stderr"): + raise OSError("EIO") + return orig(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", flaky) + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert "could not be read" in caplog.text + + +@pytest.mark.parametrize("damage", ["corrupt", "remove"]) +def test_generate_ld_scripts_damaged_note_invalidates_cache( + tmp_path: Path, caplog: pytest.LogCaptureFixture, damage: str +) -> None: + """A corrupted or externally removed diagnostic note is a cache miss: + -E re-runs and re-derives the warning instead of dropping it silently.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warn!") + with patch.object(arduino8266.subprocess, "run", return_value=result): + ld_dir = _run_generate_ld_scripts(paths) + note = ld_dir / ".local.eagle.app.v6.common.ld.stderr" + if damage == "corrupt": + note.write_bytes(b"\xff\xfe") + else: + note.unlink() + caplog.clear() + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + assert mock_run.called + assert "Linker-script preprocessor: warn!" in caplog.text + + +@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "0x10UL"]) +def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + f"-DMMU_IRAM_SIZE={value}", + "-DMMU_ICACHE_SIZE=0x8000", + ) + assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines + + +@pytest.mark.parametrize( + "flag", + [ + "-DMMU_IRAM_SIZE=48K", + # Decimal passes preprocessing but build_surgery's segment parser + # only reads hex, so testing-mode surgery would fail misleadingly + "-DMMU_IRAM_SIZE=32768", + ], +) +def test_mmu_custom_malformed_size_raises(flag: str) -> None: + """A non-hex size would corrupt the preprocessed segment lengths (or + defeat the testing-mode surgery); refuse by name.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a hex"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + flag, + "-DMMU_ICACHE_SIZE=0x8000", + ) + + +def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: + """Valueless MMU switches (MMU_IRAM_HEAP) pass; every valued MMU_* is + hex-validated, not just the two required sizes.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_IRAM_HEAP", + ) + assert "MMU_IRAM_HEAP" in config.mmu_defines + with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a numeric"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_SEC_HEAP_SIZE=48K", + ) + + +def test_mmu_custom_accepts_decimal_non_segment_values() -> None: + """MMU_EXTERNAL_HEAP=128 (the module's own EXTERNAL_128K shape) is a + mmu_iram.h count, not a segment length; decimal is legal there while + the two segment sizes stay hex-only for the surgery parser.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_EXTERNAL_HEAP=128", + ) + assert "MMU_EXTERNAL_HEAP=128" in config.mmu_defines + + +def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None: + """The no-knob branch refuses every raw MMU_*, like the knob branch; a + lone switch would win the compile line but not the linker script.""" + with pytest.raises(EsphomeError, match="Raw MMU_IRAM_HEAP"): + _resolve("-DMMU_IRAM_HEAP") + + +def test_raw_nonosdk_define_raises() -> None: + """A raw NONOSDK* define would split the compile line from the linked + SDK libraries, like the lwIP knob overrides.""" + with pytest.raises(EsphomeError, match="NONOSDK305 are set by the"): + _resolve("-DNONOSDK305=1") + + +def test_write_note_warn_level( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A lost stderr note drops a diagnostic on later cached builds, so it + warns; a lost stamp only costs a cache miss.""" + arduino8266._write_note(tmp_path / "missing" / "note", "x", warn=True) + assert "Could not write" in caplog.text + + +def test_write_note_failure_is_best_effort( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed stamp or stderr-note write costs a cache miss, never the + build.""" + caplog.set_level("DEBUG") + arduino8266._write_note(tmp_path / "missing" / "stamp", "x") + assert "Could not write" in caplog.text + + +def test_pio_option_blank_value_raises() -> None: + """An empty or blank platformio_options value is a config error, not a + silent fallback to the default.""" + CORE.platformio_options = {"board_build.f_cpu": " "} + with pytest.raises(EsphomeError, match="board_build.f_cpu is empty"): + arduino8266._pio_option("board_build.f_cpu", "80000000L") + + +@pytest.mark.parametrize("value", ["160 MHz", "\u0661\u0666\u0660"]) +def test_defines_flags_invalid_f_cpu_raises(value: str) -> None: + """A non-numeric board_build.f_cpu is rejected by name; it would land + unquoted on the compile line. Unicode digits count as non-numeric: the + shape checks are ASCII-only.""" + CORE.platformio_options = {"board_build.f_cpu": value} + with pytest.raises(EsphomeError, match="Invalid board_build.f_cpu"): + _defines_flags( + _resolve(), + "dout", + "nodemcuv2", + ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], + ) + + +def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: + """A moved rate-table anchor surfaces as a build error, not a traceback + or a silently unrelocated table.""" + paths = _make_framework(tmp_path) + result = _ok_result(stdout="SECTIONS { no anchor here }") + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + pytest.raises(EsphomeError, match="anchor not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: + """With MMU_CUSTOM also set, the actionable fix is dropping the variant + knob, not setting the knob the user already set.""" + with pytest.raises(EsphomeError, match="drop PIO_FRAMEWORK_ARDUINO_MMU_CACHE16"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0xC000", + "-DMMU_ICACHE_SIZE=0x4000", + ) + + +def test_generate_ld_scripts_testing_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """A testing-mode segment patch failing on a changed linker script is a + named error, like the ratetable surgery.""" + paths = _make_framework(tmp_path) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=RuntimeError("iram1_0_seg not found"), + ), + pytest.raises(EsphomeError, match="iram1_0_seg not found"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_testing_flash_ld_surgery_failure_is_named( + tmp_path: Path, +) -> None: + """The flash-ld segment patch gets the same named-error wrap.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( + "MEMORY { }" + ) + CORE.testing_mode = True + result = _ok_result() + with ( + patch.object(arduino8266.subprocess, "run", return_value=result), + patch.object( + arduino8266.build_surgery, + "apply_testing_memory_patches", + side_effect=["patched common", RuntimeError("dram0_0_seg mismatch")], + ), + pytest.raises(EsphomeError, match="dram0_0_seg mismatch"), + ): + _run_generate_ld_scripts(paths) + + +def test_generate_ld_scripts_reemits_cached_preprocessor_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A preprocessor diagnostic survives cache hits instead of appearing + once and vanishing for the life of the build dir.""" + paths = _make_framework(tmp_path) + result = _ok_result(stderr="warning: something odd") + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + assert caplog.text.count("warning: something odd") == 1 + with patch.object(arduino8266.subprocess, "run") as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_not_called() + assert caplog.text.count("warning: something odd") == 2 + + +def test_generate_ld_scripts_unreadable_header_forces_regeneration( + tmp_path: Path, +) -> None: + """A stat failure other than absence must miss the cache every run, not + pin the stamp to a constant that can never notice a later edit.""" + paths = _make_framework(tmp_path) + header_name = "eagle.app.v6.common.ld.h" + (paths.framework / "tools" / "sdk" / "ld" / header_name).write_text("v1") + real_stat = Path.stat + + def fake_stat(self: Path, **kwargs: object): + if self.name == header_name: + raise PermissionError(13, "denied") + return real_stat(self, **kwargs) + + result = _ok_result() + with patch.object(Path, "stat", fake_stat): + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + with patch.object( + arduino8266.subprocess, "run", return_value=result + ) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_bare_include_and_define_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + """An empty-argument -I or -D would make gcc eat the next flag as the + argument; the lex funnel warns and drops both.""" + CORE.build_flags = {'-I ""', '-D ""'} + assert arduino8266._lexed_build_flags() == [] + assert "Ignoring '-I' with empty argument" in caplog.text + assert "Ignoring '-D' with empty argument" in caplog.text + + +def test_generate_ld_scripts_gcc_change_invalidates_stamp(tmp_path: Path) -> None: + """An in-place toolchain re-extraction regenerates the script, same as + the header stat.""" + paths = _make_framework(tmp_path) + gcc = toolchain_tool(paths.toolchain, "gcc") + gcc.write_text("v1") + result = _ok_result() + with patch.object(arduino8266.subprocess, "run", return_value=result): + _run_generate_ld_scripts(paths) + gcc.write_text("v2 (longer)") + with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run: + _run_generate_ld_scripts(paths) + mock_run.assert_called_once() + + +def test_defines_flags_honors_f_cpu_override() -> None: + """board_build.f_cpu (a published-config overclock knob) reaches the + compile line; the default stays 80 MHz.""" + _set_flags() + config = _resolve_build_config(_flag_defines(set(), [])) + board_build = ESP8266_BOARD_BUILD["nodemcuv2"] + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=80000000L" in defines + CORE.platformio_options = {"board_build.f_cpu": "160000000L"} + defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) + assert "-DF_CPU=160000000L" in defines From 5e8f6203628e92082ecfd4f04356b4ab43d4d237 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:53:34 +0100 Subject: [PATCH 368/759] [voice_assistant] Use register_apply_action for start, start_continuous and stop (#19536) --- .../components/voice_assistant/__init__.py | 59 +++++-------------- .../voice_assistant/voice_assistant.h | 25 -------- .../voice_assistant/common-idf.yaml | 3 + tests/components/voice_assistant/common.yaml | 3 + 4 files changed, 21 insertions(+), 69 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d30eaf4768b..d66ef43fdc5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import register_action, register_condition +from esphome.automation import register_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -62,15 +62,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -StartAction = voice_assistant_ns.class_( - "StartAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StartContinuousAction = voice_assistant_ns.class_( - "StartContinuousAction", automation.Action, cg.Parented.template(VoiceAssistant) -) -StopAction = voice_assistant_ns.class_( - "StopAction", automation.Action, cg.Parented.template(VoiceAssistant) -) IsRunningCondition = voice_assistant_ns.class_( "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) ) @@ -406,51 +397,31 @@ async def to_code(config: ConfigType) -> None: VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssistant)}) -@register_action( +automation.register_apply_action( "voice_assistant.start_continuous", - StartContinuousAction, VOICE_ASSISTANT_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("request_start(true, true)"), ) -@register_action( +# wake_word defaults to "" so a start without one clears the previous wake word, +# as the old action did. +automation.register_apply_action( "voice_assistant.start", - StartAction, VOICE_ASSISTANT_ACTION_SCHEMA.extend( { cv.Optional(CONF_SILENCE_DETECTION, default=True): cv.boolean, - cv.Optional(CONF_WAKE_WORD): cv.templatable(cv.string), + cv.Optional(CONF_WAKE_WORD, default=""): cv.templatable(cv.string), } ), - synchronous=True, + automation.ApplyField(CONF_WAKE_WORD, "set_wake_word", cg.std_string), + automation.ApplyCall( + "request_start(false, {})", ((CONF_SILENCE_DETECTION, cg.bool_),) + ), ) -async def voice_assistant_listen_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if CONF_SILENCE_DETECTION in config: - cg.add(var.set_silence_detection(config[CONF_SILENCE_DETECTION])) - if wake_word := config.get(CONF_WAKE_WORD): - templ = await cg.templatable(wake_word, args, cg.std_string) - cg.add(var.set_wake_word(templ)) - return var - - -@register_action( - "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "voice_assistant.stop", + VOICE_ASSISTANT_ACTION_SCHEMA, + automation.ApplyCall("request_stop()"), ) -async def voice_assistant_stop_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @register_condition( diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index d46b089c2ea..88ddb2066a0 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -347,31 +347,6 @@ class VoiceAssistant final : public Component { #endif }; -template class StartAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, wake_word); - - public: - void play(const Ts &...x) override { - this->parent_->set_wake_word(this->wake_word_.value(x...)); - this->parent_->request_start(false, this->silence_detection_); - } - - void set_silence_detection(bool silence_detection) { this->silence_detection_ = silence_detection; } - - protected: - bool silence_detection_; -}; - -template class StartContinuousAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_start(true, true); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->request_stop(); } -}; - template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 812e7a2314c..998ba90dd99 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 8604bea795c..5a5791899e2 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -2,6 +2,9 @@ esphome: on_boot: then: - voice_assistant.start + - voice_assistant.start: + wake_word: okay_nabu + silence_detection: false - voice_assistant.start_continuous - voice_assistant.stop From 4e2d1cd7fc205cddbd3873b2c3ad2f66c53c6c95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:55:16 +0100 Subject: [PATCH 369/759] [hlk_fm22x] Use register_apply_action for scan, enroll, delete, delete_all and reset (#19537) --- esphome/components/hlk_fm22x/__init__.py | 63 +++++------------------- esphome/components/hlk_fm22x/hlk_fm22x.h | 37 -------------- tests/components/hlk_fm22x/common.yaml | 7 +++ 3 files changed, 20 insertions(+), 87 deletions(-) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index c1aa81f6d42..b8040e55794 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -27,11 +27,6 @@ HlkFm22xComponent = hlk_fm22x_ns.class_( "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) -DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) -ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action) -ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -101,9 +96,8 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.enroll", - EnrollmentAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -112,22 +106,14 @@ async def to_code(config): }, key=CONF_NAME, ), - synchronous=True, + automation.ApplyCall( + "enroll_face({}, static_cast({}))", + ((CONF_NAME, cg.std_string), (CONF_DIRECTION, cg.uint8)), + ), ) -async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string) - cg.add(var.set_name(template_)) - template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8) - cg.add(var.set_direction(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete", - DeleteAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), @@ -135,60 +121,37 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): }, key=CONF_FACE_ID, ), - synchronous=True, + automation.ApplyField(CONF_FACE_ID, "delete_face", cg.int16), ) -async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16) - cg.add(var.set_face_id(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.delete_all", - DeleteAllAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("delete_all_faces()"), ) -async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.scan", - ScanAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("scan_face()"), ) -async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var -@automation.register_action( +automation.register_apply_action( "hlk_fm22x.reset", - ResetAction, cv.Schema( { cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) -async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 3bdf6e2c71c..8b219f3ed2e 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -141,41 +141,4 @@ class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, name) - TEMPLATABLE_VALUE(uint8_t, direction) - - void play(const Ts &...x) override { - auto name = this->name_.value(x...); - auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); - this->parent_->enroll_face(name, direction); - } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(int16_t, face_id) - - void play(const Ts &...x) override { - auto face_id = this->face_id_.value(x...); - this->parent_->delete_face(face_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_faces(); } -}; - -template class ScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->scan_face(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - } // namespace esphome::hlk_fm22x diff --git a/tests/components/hlk_fm22x/common.yaml b/tests/components/hlk_fm22x/common.yaml index 6fcd9af5949..1371bd8e95c 100644 --- a/tests/components/hlk_fm22x/common.yaml +++ b/tests/components/hlk_fm22x/common.yaml @@ -4,11 +4,18 @@ esphome: - hlk_fm22x.enroll: name: "Test" direction: 1 + - hlk_fm22x.delete: 5 - hlk_fm22x.delete_all: + - hlk_fm22x.reset: + - hlk_fm22x.scan: hlk_fm22x: on_face_scan_matched: - logger.log: test_hlk_22x_face_scan_matched + - hlk_fm22x.enroll: + name: !lambda 'return name + "-again";' + direction: !lambda 'return face_id % 3;' + - hlk_fm22x.delete: !lambda 'return face_id;' on_face_scan_unmatched: - logger.log: test_hlk_22x_face_scan_unmatched on_face_scan_invalid: From 01f9416a79c025ebb9cb2468d23fa9e547178253 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:55:58 +0100 Subject: [PATCH 370/759] [text_sensor] Use register_apply_action for text_sensor.template.publish (#19539) --- esphome/components/template/text_sensor/__init__.py | 12 ++---------- esphome/components/text_sensor/__init__.py | 3 --- esphome/components/text_sensor/automation.h | 11 ----------- tests/components/text_sensor/common.yaml | 11 +++++++++++ 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index 77f5c2ff7cf..32e616e6f9f 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -1,7 +1,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import text_sensor -from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE @@ -38,20 +37,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "text_sensor.template.publish", - TextSensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(text_sensor.TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.std_string), ) -async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 5c8d71696f5..35e33f237cc 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -43,9 +43,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorPublishAction = text_sensor_ns.class_( - "TextSensorPublishAction", automation.Action -) TextSensorStateCondition = text_sensor_ns.class_( "TextSensorStateCondition", automation.Condition ) diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index 628b9b84a0f..ac130afa331 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -34,15 +34,4 @@ template class TextSensorStateCondition final : public Condition TextSensor *parent_; }; -template class TextSensorPublishAction final : public Action { - public: - TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(std::string, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - TextSensor *sensor_; -}; - } // namespace esphome::text_sensor diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index 97b0b8ad942..ce1dd3a3f2b 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -77,3 +77,14 @@ text_sensor: return {x.substr(0, 10) + "..."}; } return {x}; + + - platform: template + name: "Test Publish Action" + id: test_publish_action + on_value: + - text_sensor.template.publish: + id: test_map_single + state: "ON" + - text_sensor.template.publish: + id: test_map_single + state: !lambda 'return x + " again";' From bbd8472b66d5607df8fb8e89c05e6d06dd8878c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 22:57:32 +0100 Subject: [PATCH 371/759] [espnow] Use register_apply_action for peer.add, peer.delete and set_channel (#19538) --- esphome/components/espnow/__init__.py | 72 +++++++------------- esphome/components/espnow/automation.h | 33 --------- esphome/components/espnow/espnow_component.h | 9 +++ tests/components/espnow/common.yaml | 7 ++ 4 files changed, 41 insertions(+), 80 deletions(-) diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 14d099ec062..c04cf1ed59b 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -39,9 +39,6 @@ ESPNowRecvInfo = espnow_ns.class_("ESPNowRecvInfo") ESPNowRecvInfoConstRef = ESPNowRecvInfo.operator("const").operator("ref") SendAction = espnow_ns.class_("SendAction", automation.Action) -SetChannelAction = espnow_ns.class_("SetChannelAction", automation.Action) -AddPeerAction = espnow_ns.class_("AddPeerAction", automation.Action) -DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, @@ -232,12 +229,16 @@ def _validate_raw_data(value: Any) -> str | list: ) +def _mac_bytes(address: core.MACAddress) -> list[HexInt]: + return [HexInt(p) for p in address.parts] + + async def register_peer( var: MockObj, config: ConfigType, args: TemplateArgsType ) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): - peer = [HexInt(p) for p in peer.parts] + peer = _mac_bytes(peer) template_ = await cg.templatable(peer, args, peer_address_t, peer_address_t) cg.add(var.set_address(template_)) @@ -323,40 +324,28 @@ async def send_action( return var -@automation.register_action( - "espnow.peer.add", - AddPeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -@automation.register_action( - "espnow.peer.delete", - DeletePeerAction, - cv.maybe_simple_value( - PEER_SCHEMA, - key=CONF_ADDRESS, - ), - synchronous=True, -) -async def peer_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - await register_peer(var, config, args) - - return var +def _peer_address(config: ConfigType, value: core.MACAddress) -> str: + return str(cg.safe_exp(_mac_bytes(value))) -@automation.register_action( +for _name, _method in ( + ("espnow.peer.add", "add_peer_from_action"), + ("espnow.peer.delete", "del_peer_from_action"), +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + PEER_SCHEMA, + key=CONF_ADDRESS, + ), + automation.ApplyField( + CONF_ADDRESS, _method, peer_address_t, const_fn=_peer_address + ), + ) + + +automation.register_apply_action( "espnow.set_channel", - SetChannelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(ESPNowComponent), @@ -364,16 +353,5 @@ async def peer_action( }, key=CONF_CHANNEL, ), - synchronous=True, + automation.ApplyField(CONF_CHANNEL, "set_channel_from_action", cg.uint8), ) -async def channel_action( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - return var diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index e4d01bb1a8b..3c9c6e8be95 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -86,39 +86,6 @@ template class SendAction final : public Action, public P } flags_{0}; }; -template class AddPeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->add_peer(address.data()); - } -}; - -template class DeletePeerAction final : public Action, public Parented { - TEMPLATABLE_VALUE(peer_address_t, address); - - protected: - void play(const Ts &...x) override { - peer_address_t address = this->address_.value(x...); - this->parent_->del_peer(address.data()); - } -}; - -template class SetChannelAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, channel) - - protected: - void play(const Ts &...x) override { - if (this->parent_->is_wifi_enabled()) { - return; - } - this->parent_->set_wifi_channel(this->channel_.value(x...)); - this->parent_->apply_wifi_channel(); - } -}; - class OnReceiveTrigger final : public Trigger, public ESPNowReceivedPacketHandler { public: diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index af693b47cfc..375bd4faefe 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -116,9 +116,18 @@ class ESPNowComponent final : public Component { esp_err_t add_peer(const uint8_t *peer); // Remove a peer with the esp_now api and remove from the internal list if exists esp_err_t del_peer(const uint8_t *peer); + // Action entry points; distinct names because add_peer(peer_address_t) only fills the boot-time list + esp_err_t add_peer_from_action(const peer_address_t &address) { return this->add_peer(address.data()); } + esp_err_t del_peer_from_action(const peer_address_t &address) { return this->del_peer(address.data()); } void set_wifi_channel(uint8_t channel) { this->wifi_channel_ = channel; } void apply_wifi_channel(); + void set_channel_from_action(uint8_t channel) { + if (this->is_wifi_enabled()) + return; + this->set_wifi_channel(channel); + this->apply_wifi_channel(); + } uint8_t get_wifi_channel(); void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 2f82e794c42..fec0dd9f3e8 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -38,8 +38,15 @@ espnow: data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};' - espnow.peer.add: address: 11:22:33:44:55:66 + - espnow.peer.add: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' - espnow.peer.delete: address: 11:22:33:44:55:66 + - espnow.peer.delete: + address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};' + - espnow.set_channel: 6 + - espnow.set_channel: + channel: !lambda 'return 6;' on_broadcast: - logger.log: format: "Broadcast from: %s = '%s' RSSI: %d" From 2e809c3626dd485b667328ed6b13772a03dfec64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:13:44 +0100 Subject: [PATCH 372/759] [pulse_counter] Use register_apply_action for pulse_counter.set_total_pulses (#19532) --- esphome/components/pulse_counter/automation.h | 21 ----------------- esphome/components/pulse_counter/sensor.py | 23 +++---------------- tests/components/pulse_counter/common.yaml | 8 +++++++ 3 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 esphome/components/pulse_counter/automation.h diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h deleted file mode 100644 index 380ef023049..00000000000 --- a/esphome/components/pulse_counter/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/pulse_counter/pulse_counter_sensor.h" - -namespace esphome::pulse_counter { - -template class SetTotalPulsesAction final : public Action { - public: - SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} - - TEMPLATABLE_VALUE(uint32_t, total_pulses) - - void play(const Ts &...x) override { this->pulse_counter_->set_total_pulses(this->total_pulses_.value(x...)); } - - protected: - PulseCounterSensor *pulse_counter_; -}; - -} // namespace esphome::pulse_counter diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 7c5a0590d75..677cfb6a5e1 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -21,8 +21,7 @@ from esphome.const import ( UNIT_PULSES, UNIT_PULSES_PER_MINUTE, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType CONF_USE_PCNT = "use_pcnt" @@ -41,10 +40,6 @@ PulseCounterSensor = pulse_counter_ns.class_( "PulseCounterSensor", sensor.Sensor, cg.PollingComponent ) -SetTotalPulsesAction = pulse_counter_ns.class_( - "SetTotalPulsesAction", automation.Action -) - def validate_internal_filter(value: ConfigType) -> ConfigType: use_pcnt = value.get(CONF_USE_PCNT) @@ -150,25 +145,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_total_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "pulse_counter.set_total_pulses", - SetTotalPulsesAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PulseCounterSensor), cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_total_pulses", cg.uint32), ) -async def set_total_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.uint32) - cg.add(var.set_total_pulses(template_)) - return var diff --git a/tests/components/pulse_counter/common.yaml b/tests/components/pulse_counter/common.yaml index 556b43ee6fa..2b27d5467b0 100644 --- a/tests/components/pulse_counter/common.yaml +++ b/tests/components/pulse_counter/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: pulse_counter + id: pulse_counter_sensor name: Pulse Counter pin: 4 count_mode: @@ -7,3 +8,10 @@ sensor: falling_edge: DECREMENT internal_filter: 13us update_interval: 15s + on_value: + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: 12345 + - pulse_counter.set_total_pulses: + id: pulse_counter_sensor + value: !lambda return (uint32_t) x; From 761dd9dd9cc68849cd60a1d861190e4ad50a18d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:10 +0100 Subject: [PATCH 373/759] [rp2_ble_tracker] Use register_apply_action for stop_scan (#19524) --- esphome/components/rp2_ble_tracker/__init__.py | 15 ++------------- esphome/components/rp2_ble_tracker/automation.h | 5 ----- .../rp2_ble_tracker/test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index b744aee31c8..12857a556fc 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -42,7 +42,6 @@ RP2BLETracker = rp2_ble_tracker_ns.class_( ) StartScanAction = rp2_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = rp2_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -156,9 +155,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "rp2_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -166,14 +164,5 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/rp2_ble_tracker/automation.h b/esphome/components/rp2_ble_tracker/automation.h index a3cede236af..7c64975077c 100644 --- a/esphome/components/rp2_ble_tracker/automation.h +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::rp2_ble_tracker #endif // USE_RP2 diff --git a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py index fdcf2bcf757..1106fd01eb9 100644 --- a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -37,12 +37,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 8f14adaa0c1afa0790e4d00700094aaace86df9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:25 +0100 Subject: [PATCH 374/759] [bk72xx_ble_tracker] Use register_apply_action for stop_scan (#19522) --- esphome/components/bk72xx_ble_tracker/__init__.py | 15 ++------------- .../components/bk72xx_ble_tracker/automation.h | 5 ----- .../test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 96b3536601b..55013d40876 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -53,7 +53,6 @@ BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( ) StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -121,9 +120,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "bk72xx_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -131,17 +129,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h index 9017d19d71e..f785b3eb3a1 100644 --- a/esphome/components/bk72xx_ble_tracker/automation.h +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -38,11 +38,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::bk72xx_ble_tracker #endif // USE_LIBRETINY diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 777ae76b4f0..3f5280d9659 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -39,12 +39,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 4f6af3cfabaed01eb9e48a0a69d9c718b3f57ec1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:15:51 +0100 Subject: [PATCH 375/759] [alarm_control_panel] Use register_apply_action for arm_away, arm_home, arm_night and disarm (#19521) --- .../alarm_control_panel/__init__.py | 76 ++++--------------- .../alarm_control_panel/automation.h | 48 ------------ .../alarm_control_panel/common.yaml | 25 ++++++ 3 files changed, 39 insertions(+), 110 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 2f5d4c7c2bf..001ecd88616 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -41,10 +41,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action) -ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action) -ArmNightAction = alarm_control_panel_ns.class_("ArmNightAction", automation.Action) -DisarmAction = alarm_control_panel_ns.class_("DisarmAction", automation.Action) PendingAction = alarm_control_panel_ns.class_("PendingAction", automation.Action) TriggeredAction = alarm_control_panel_ns.class_("TriggeredAction", automation.Action) ChimeAction = alarm_control_panel_ns.class_("ChimeAction", automation.Action) @@ -196,64 +192,20 @@ async def new_alarm_control_panel(config, *args): return var -@automation.register_action( - "alarm_control_panel.arm_away", - ArmAwayAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_home", - ArmHomeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.arm_night", - ArmNightAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_CODE in config: - templatable_ = await cg.templatable(config[CONF_CODE], args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var - - -@automation.register_action( - "alarm_control_panel.disarm", - DisarmAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) -async def alarm_action_disarm_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if code_config := config.get(CONF_CODE): - templatable_ = await cg.templatable(code_config, args, cg.std_string) - cg.add(var.set_code(templatable_)) - return var +# Mirrors AlarmControlPanel::arm_with_code_: arm first, set the code only when given. +for _name, _arm in ( + ("alarm_control_panel.arm_away", "arm_away()"), + ("alarm_control_panel.arm_home", "arm_home()"), + ("alarm_control_panel.arm_night", "arm_night()"), + ("alarm_control_panel.disarm", "disarm()"), +): + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_arm), + automation.ApplyField(CONF_CODE, "set_code", cg.std_string), + call="make_call", + ) @automation.register_action( diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index dcb5121c60f..cd9b5eadced 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,54 +27,6 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction final : public Action { - public: - explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_away(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmHomeAction final : public Action { - public: - explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_home(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class ArmNightAction final : public Action { - public: - explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->arm_night(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class DisarmAction final : public Action { - public: - explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - TEMPLATABLE_VALUE(std::string, code) - - void play(const Ts &...x) override { this->alarm_control_panel_->disarm(this->code_.optional_value(x...)); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} diff --git a/tests/components/alarm_control_panel/common.yaml b/tests/components/alarm_control_panel/common.yaml index 327234d6caa..f9c26ec5c9d 100644 --- a/tests/components/alarm_control_panel/common.yaml +++ b/tests/components/alarm_control_panel/common.yaml @@ -67,3 +67,28 @@ alarm_control_panel: on_cleared: then: - logger.log: "### CLEARED ###" + +button: + # Exercise the arm/disarm actions with a constant code, a lambda code and no code. + - platform: template + name: Alarm Panel Actions + on_press: + - alarm_control_panel.arm_away: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_home: + id: alarmcontrolpanel1 + code: !lambda |- + std::string code = "56"; + code += "78"; + return code; + - alarm_control_panel.arm_night: + id: alarmcontrolpanel1 + code: "0000" + - alarm_control_panel.disarm: + id: alarmcontrolpanel1 + code: "1234" + - alarm_control_panel.arm_away: alarmcontrolpanel2 + - alarm_control_panel.arm_home: alarmcontrolpanel2 + - alarm_control_panel.arm_night: alarmcontrolpanel2 + - alarm_control_panel.disarm: alarmcontrolpanel2 From 170bb02fa35328bb841dbf0a18305496e5d23f0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:16:08 +0100 Subject: [PATCH 376/759] [ln882h_ble_tracker] Use register_apply_action for stop_scan (#19523) --- esphome/components/ln882h_ble_tracker/__init__.py | 15 ++------------- .../components/ln882h_ble_tracker/automation.h | 5 ----- .../test_automations_codegen.py | 5 +++-- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 4bfaa93ab7a..69a01ae1acf 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -37,7 +37,6 @@ LN882HBLETracker = ln882h_ble_tracker_ns.class_( ) StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) -StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger @@ -103,9 +102,8 @@ async def start_scan_action_to_code( return var -@automation.register_action( +automation.register_apply_action( "ln882h_ble_tracker.stop_scan", - StopScanAction, automation.maybe_simple_id( cv.Schema( { @@ -113,17 +111,8 @@ async def start_scan_action_to_code( } ) ), - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: list, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h index 8b211384a0c..98e58200fd1 100644 --- a/esphome/components/ln882h_ble_tracker/automation.h +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::ln882h_ble_tracker #endif // USE_LIBRETINY diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py index 608a4c66941..c1ef283ea33 100644 --- a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -34,12 +34,13 @@ def test_trigger_codegen( "set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," "0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp ) - # scan-control actions: templatable continuous lambda + parented actions. + # scan-control actions: templatable continuous lambda + stop_scan forwarded + # straight to the tracker (register_apply_action, no action class). # Exactly one set_continuous: the bare start_scan emits none, pinning the # restore-configured-mode divergence from esp32 against a future default=. assert main_cpp.count("->set_continuous(") == 1 assert "startscanaction_id->set_continuous(" in main_cpp - assert "stopscanaction_id->set_parent(" in main_cpp + assert main_cpp.count("->stop_scan();") == 1 # scan_parameters continuous: false reaches the YAML-mode setter, not the # runtime override. assert "->set_configured_continuous(false)" in main_cpp From 0110215e31ffde1da44d3f3b9ebe8f48a2701624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:20:05 +0100 Subject: [PATCH 377/759] [template] Use register_apply_action for lock.template.publish and valve.template.publish (#19525) --- esphome/components/template/lock/__init__.py | 18 ++--------- esphome/components/template/lock/automation.h | 16 ---------- esphome/components/template/valve/__init__.py | 32 ++++++------------- .../components/template/valve/automation.h | 22 ------------- tests/components/template/common-base.yaml | 15 +++++++++ 5 files changed, 26 insertions(+), 77 deletions(-) delete mode 100644 esphome/components/template/lock/automation.h delete mode 100644 esphome/components/template/valve/automation.h diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index d8bd9d16c66..95a359c67ce 100644 --- a/esphome/components/template/lock/__init__.py +++ b/esphome/components/template/lock/__init__.py @@ -4,7 +4,6 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, - CONF_ID, CONF_LAMBDA, CONF_LOCK_ACTION, CONF_OPEN_ACTION, @@ -17,12 +16,6 @@ from .. import template_ns TemplateLock = template_ns.class_("TemplateLock", lock.Lock, cg.Component) -TemplateLockPublishAction = template_ns.class_( - "TemplateLockPublishAction", - automation.Action, - cg.Parented.template(TemplateLock), -) - def validate(config): if not config[CONF_OPTIMISTIC] and ( @@ -80,9 +73,8 @@ async def to_code(config): cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) -@automation.register_action( +automation.register_apply_action( "lock.template.publish", - TemplateLockPublishAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(TemplateLock), @@ -90,11 +82,5 @@ async def to_code(config): }, key=CONF_STATE, ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", lock.LockState), ) -async def lock_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, lock.LockState) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h deleted file mode 100644 index a979291b785..00000000000 --- a/esphome/components/template/lock/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "template_lock.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateLockPublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(lock::LockState, state) - - void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 11b35dad23a..a7fd6f7632c 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, CONF_DEVICE_CLASS, - CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, CONF_OPTIMISTIC, @@ -22,10 +21,6 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) -TemplateValvePublishAction = template_ns.class_( - "TemplateValvePublishAction", automation.Action, cg.Parented.template(TemplateValve) -) - TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") RESTORE_MODES = { "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, @@ -104,9 +99,10 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) -@automation.register_action( +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both write the position. +automation.register_apply_action( "valve.template.publish", - TemplateValvePublishAction, cv.Schema( { cv.GenerateID(): cv.use_id(TemplateValve), @@ -117,20 +113,10 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", valve.ValveOperation + ), + automation.ApplyCall("publish_state()"), ) -async def valve_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, cg.float_) - cg.add(var.set_position(template_)) - if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, cg.float_) - cg.add(var.set_position(template_)) - if current_operation_config := config.get(CONF_CURRENT_OPERATION): - template_ = await cg.templatable( - current_operation_config, args, valve.ValveOperation - ) - cg.add(var.set_current_operation(template_)) - return var diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h deleted file mode 100644 index ec9d784ab65..00000000000 --- a/esphome/components/template/valve/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "template_valve.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateValvePublishAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) - - void play(const Ts &...x) override { - if (this->position_.has_value()) - this->parent_->position = this->position_.value(x...); - if (this->current_operation_.has_value()) - this->parent_->current_operation = this->current_operation_.value(x...); - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index fd868b97098..81b13de7bf0 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -475,6 +475,7 @@ select: lock: - platform: template + id: template_lock name: "Template Lock" lambda: |- if (id(template_some_binary_sensor).state) { @@ -483,8 +484,14 @@ lock: return LOCK_STATE_UNLOCKED; lock_action: - logger.log: lock_action + - lock.template.publish: + id: template_lock + state: LOCKED unlock_action: - logger.log: unlock_action + - lock.template.publish: + id: template_lock + state: !lambda "return LOCK_STATE_UNLOCKED;" open_action: - logger.log: open_action @@ -504,6 +511,14 @@ valve: - valve.template.publish: id: template_valve state: CLOSED + - valve.template.publish: + id: template_valve + position: 50% + current_operation: IDLE + - valve.template.publish: + id: template_valve + position: !lambda "return 0.25f;" + current_operation: !lambda "return VALVE_OPERATION_CLOSING;" stop_action: - logger.log: stop_action # Exercise valve.control with various field combinations so the From bd1efeae9498ca1526135e544127d1942b0f6db7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:20:29 +0100 Subject: [PATCH 378/759] [servo] Use register_apply_action for servo.write and servo.detach (#19531) --- esphome/components/servo/__init__.py | 34 ++++------------------------ esphome/components/servo/servo.h | 22 ------------------ tests/components/servo/common.yaml | 3 +++ 3 files changed, 7 insertions(+), 52 deletions(-) diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 666c7dbcddf..74acaa34962 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -13,14 +13,10 @@ from esphome.const import ( CONF_RESTORE, CONF_TRANSITION_LENGTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) -ServoWriteAction = servo_ns.class_("ServoWriteAction", automation.Action) -ServoDetachAction = servo_ns.class_("ServoDetachAction", automation.Action) CONF_AUTO_DETACH_TIME = "auto_detach_time" MULTI_CONF = True @@ -56,45 +52,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transition_length(config[CONF_TRANSITION_LENGTH])) -@automation.register_action( +automation.register_apply_action( "servo.write", - ServoWriteAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(Servo), cv.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "write", cg.float_), ) -async def servo_write_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_value(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "servo.detach", - ServoDetachAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Servo), } ), - synchronous=True, + automation.ApplyCall("detach()"), ) -async def servo_detach_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 156dab6dc1e..ce29c783e20 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -51,25 +50,4 @@ class Servo final : public Component { }; }; -template class ServoWriteAction final : public Action { - public: - ServoWriteAction(Servo *servo) : servo_(servo) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->servo_->write(this->value_.value(x...)); } - - protected: - Servo *servo_; -}; - -template class ServoDetachAction final : public Action { - public: - ServoDetachAction(Servo *servo) : servo_(servo) {} - - void play(const Ts &...x) override { this->servo_->detach(); } - - protected: - Servo *servo_; -}; - } // namespace esphome::servo diff --git a/tests/components/servo/common.yaml b/tests/components/servo/common.yaml index 2cfa370c32a..797d05f41fb 100644 --- a/tests/components/servo/common.yaml +++ b/tests/components/servo/common.yaml @@ -4,6 +4,9 @@ esphome: - servo.write: id: test_servo level: -100.0% + - servo.write: + id: test_servo + level: !lambda return 0.5; - servo.detach: test_servo output: From 606525ed18e50586caef7bd9cc84fcf7b3a863d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:21:21 +0100 Subject: [PATCH 379/759] [output] Use register_apply_action for the output actions (#19512) --- esphome/components/output/__init__.py | 113 ++++++++++--------------- esphome/components/output/automation.h | 69 --------------- tests/components/output/common.yaml | 3 + 3 files changed, 48 insertions(+), 137 deletions(-) delete mode 100644 esphome/components/output/automation.h diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 10d5e5eb593..1b794037848 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,12 +14,26 @@ from esphome.const import ( CONF_POWER_SUPPLY, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True +DOMAIN = "output" CONF_ZERO_MEANS_ZERO = "zero_means_zero" + +@dataclass +class OutputData: + power_scaling: bool = False + + +def _get_data() -> OutputData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = OutputData() + return CORE.data[DOMAIN] + + BINARY_OUTPUT_SCHEMA = cv.Schema( { cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), @@ -39,13 +55,6 @@ BinaryOutputPtr = BinaryOutput.operator("ptr") FloatOutput = output_ns.class_("FloatOutput", BinaryOutput) FloatOutputPtr = FloatOutput.operator("ptr") -# Actions -TurnOffAction = output_ns.class_("TurnOffAction", automation.Action) -TurnOnAction = output_ns.class_("TurnOnAction", automation.Action) -SetLevelAction = output_ns.class_("SetLevelAction", automation.Action) -SetMinPowerAction = output_ns.class_("SetMinPowerAction", automation.Action) -SetMaxPowerAction = output_ns.class_("SetMaxPowerAction", automation.Action) - async def setup_output_platform_(obj, config): if CONF_INVERTED in config: @@ -85,81 +94,49 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "output.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +def _enable_power_scaling(config: ConfigType) -> ConfigType: + # set_min_power/set_max_power only exist with the define; to_code emits it from this fact. + _get_data().power_scaling = True + return config -@automation.register_action( - "output.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True -) -async def output_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("output.turn_on", "turn_on()"), + ("output.turn_off", "turn_off()"), +): + automation.register_apply_action( + _name, BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "output.set_level", - SetLevelAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(FloatOutput), cv.Required(CONF_LEVEL): cv.templatable(cv.percentage), } ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_level", cg.float_), ) -async def output_set_level_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.float_) - cg.add(var.set_level(template_)) - return var - -@automation.register_action( - "output.set_min_power", - SetMinPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_min_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) - cg.add(var.set_min_power(template_)) - return var - - -@automation.register_action( - "output.set_max_power", - SetMaxPowerAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(FloatOutput), - cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), - } - ), - synchronous=True, -) -async def output_set_max_power_to_code(config, action_id, template_arg, args): - cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) - cg.add(var.set_max_power(template_)) - return var +for _name, _key, _target in ( + ("output.set_min_power", CONF_MIN_POWER, "set_min_power"), + ("output.set_max_power", CONF_MAX_POWER, "set_max_power"), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(FloatOutput), + cv.Required(_key): cv.templatable(cv.percentage), + } + ).add_extra(_enable_power_scaling), + automation.ApplyField(_key, _target, cg.float_), + ) async def to_code(config): cg.add_define("USE_OUTPUT") + if _get_data().power_scaling: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add_global(output_ns.using) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h deleted file mode 100644 index efe775ba57d..00000000000 --- a/esphome/components/output/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/core/defines.h" -#include "esphome/components/output/binary_output.h" -#include "esphome/components/output/float_output.h" - -namespace esphome::output { - -template class TurnOffAction final : public Action { - public: - TurnOffAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_off(); } - - protected: - BinaryOutput *output_; -}; - -template class TurnOnAction final : public Action { - public: - TurnOnAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_on(); } - - protected: - BinaryOutput *output_; -}; - -template class SetLevelAction final : public Action { - public: - SetLevelAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_level(this->level_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -#ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction final : public Action { - public: - SetMinPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, min_power) - - void play(const Ts &...x) override { this->output_->set_min_power(this->min_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -template class SetMaxPowerAction final : public Action { - public: - SetMaxPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, max_power) - - void play(const Ts &...x) override { this->output_->set_max_power(this->max_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; -#endif // USE_OUTPUT_FLOAT_POWER_SCALING - -} // namespace esphome::output diff --git a/tests/components/output/common.yaml b/tests/components/output/common.yaml index df20dcde2b0..6448b3cfe6d 100644 --- a/tests/components/output/common.yaml +++ b/tests/components/output/common.yaml @@ -6,6 +6,9 @@ esphome: - output.set_level: id: output_light_output_1 level: 50% + - output.set_level: + id: output_light_output_1 + level: !lambda return 0.25f; - output.set_min_power: id: output_light_output_1 min_power: 20% From 62b501049f69a7f3ba82296534e6a8dc52219236 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:21:39 +0100 Subject: [PATCH 380/759] [datetime] Use register_apply_action for date.set, time.set and datetime.set (#19513) --- esphome/components/datetime/__init__.py | 177 ++++++------------ esphome/components/datetime/date_entity.h | 14 -- esphome/components/datetime/datetime_entity.h | 14 -- esphome/components/datetime/time_entity.h | 14 -- tests/components/datetime/common.yaml | 40 ++++ 5 files changed, 100 insertions(+), 159 deletions(-) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index f8b6446006e..3a33e5e0044 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, time, web_server @@ -21,13 +23,13 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -40,11 +42,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -# Actions -DateSetAction = datetime_ns.class_("DateSetAction", automation.Action) -TimeSetAction = datetime_ns.class_("TimeSetAction", automation.Action) -DateTimeSetAction = datetime_ns.class_("DateTimeSetAction", automation.Action) - DateTimeStateTrigger = datetime_ns.class_( "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) ) @@ -181,118 +178,64 @@ async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) -@automation.register_action( - "datetime.date.set", - DateSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateEntity), - cv.Required(CONF_DATE): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=False) - ), - } - ), - synchronous=True, -) -async def datetime_date_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - date_config = config[CONF_DATE] - if cg.is_template(date_config): - template_ = await cg.templatable(date_config, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - else: - date_struct = cg.StructInitializer( - cg.ESPTime, - ("day_of_month", date_config[CONF_DAY]), - ("month", date_config[CONF_MONTH]), - ("year", date_config[CONF_YEAR]), +def _esptime_initializer( + members: tuple[tuple[str, str], ...], +) -> Callable[[ConfigType, ConfigType], str]: + def const_fn(config: ConfigType, value: ConfigType) -> str: + return str( + cg.StructInitializer( + cg.ESPTime, *((member, value[key]) for member, key in members) + ) ) - template_ = await cg.templatable(date_struct, args, cg.ESPTime) - cg.add(action_var.set_date(template_)) - return action_var + + return const_fn -@automation.register_action( - "datetime.time.set", - TimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(TimeEntity), - cv.Required(CONF_TIME): cv.Any( - cv.returning_lambda, cv.date_time(date=False, time=True) - ), - } +# ESPTime member order, so the designated initializer compiles. +_TIME_MEMBERS = (("second", CONF_SECOND), ("minute", CONF_MINUTE), ("hour", CONF_HOUR)) +_DATE_MEMBERS = (("day_of_month", CONF_DAY), ("month", CONF_MONTH), ("year", CONF_YEAR)) + +for _name, _entity, _key, _target, _date, _time, _members in ( + ( + "datetime.date.set", + DateEntity, + CONF_DATE, + "set_date", + True, + False, + _DATE_MEMBERS, ), - synchronous=True, -) -async def datetime_time_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - time_config = config[CONF_TIME] - if cg.is_template(time_config): - template_ = await cg.templatable(time_config, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - else: - time_struct = cg.StructInitializer( - cg.ESPTime, - ("second", time_config[CONF_SECOND]), - ("minute", time_config[CONF_MINUTE]), - ("hour", time_config[CONF_HOUR]), - ) - template_ = await cg.templatable(time_struct, args, cg.ESPTime) - cg.add(action_var.set_time(template_)) - return action_var - - -@automation.register_action( - "datetime.datetime.set", - DateTimeSetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(DateTimeEntity), - cv.Required(CONF_DATETIME): cv.Any( - cv.returning_lambda, cv.date_time(date=True, time=True) - ), - }, + ( + "datetime.time.set", + TimeEntity, + CONF_TIME, + "set_time", + False, + True, + _TIME_MEMBERS, ), - synchronous=True, -) -async def datetime_datetime_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - action_var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(action_var, config[CONF_ID]) - - datetime_config = config[CONF_DATETIME] - if cg.is_template(datetime_config): - template_ = await cg.templatable(datetime_config, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - else: - datetime_struct = cg.StructInitializer( - cg.ESPTime, - ("second", datetime_config[CONF_SECOND]), - ("minute", datetime_config[CONF_MINUTE]), - ("hour", datetime_config[CONF_HOUR]), - ("day_of_month", datetime_config[CONF_DAY]), - ("month", datetime_config[CONF_MONTH]), - ("year", datetime_config[CONF_YEAR]), - ) - template_ = await cg.templatable(datetime_struct, args, cg.ESPTime) - cg.add(action_var.set_datetime(template_)) - return action_var + ( + "datetime.datetime.set", + DateTimeEntity, + CONF_DATETIME, + "set_datetime", + True, + True, + _TIME_MEMBERS + _DATE_MEMBERS, + ), +): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(_entity), + cv.Required(_key): cv.Any( + cv.returning_lambda, cv.date_time(date=_date, time=_time) + ), + } + ), + automation.ApplyField( + _key, _target, cg.ESPTime, const_fn=_esptime_initializer(_members) + ), + call="make_call", + ) diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 93ce1411f87..9404d215120 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -98,20 +98,6 @@ class DateCall { inline DateCall DateEntity::make_call() { return DateCall(this); } -template class DateSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, date) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->date_.has_value()) { - call.set_date(this->date_.value(x...)); - } - call.perform(); - } -}; - } // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index fec620b5ba9..57d305cb52b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -123,20 +123,6 @@ class DateTimeCall { inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } -template class DateTimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, datetime) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->datetime_.has_value()) { - call.set_datetime(this->datetime_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 736e26f4a7a..fd31318ce5f 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -100,20 +100,6 @@ class TimeCall { inline TimeCall TimeEntity::make_call() { return TimeCall(this); } -template class TimeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(ESPTime, time) - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - - if (this->time_.has_value()) { - call.set_time(this->time_.value(x...)); - } - call.perform(); - } -}; - #ifdef USE_TIME class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: diff --git a/tests/components/datetime/common.yaml b/tests/components/datetime/common.yaml index 4e26b681212..add75645292 100644 --- a/tests/components/datetime/common.yaml +++ b/tests/components/datetime/common.yaml @@ -1,3 +1,43 @@ +esphome: + on_boot: + then: + - datetime.date.set: + id: datetime_test_date + date: + year: 2021 + month: 1 + day: 1 + - datetime.date.set: + id: datetime_test_date + date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};" + - datetime.time.set: + id: datetime_test_time + time: "12:34:56" + - datetime.time.set: + id: datetime_test_time + time: !lambda "return {.second = 56, .minute = 34, .hour = 12};" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: "2021-01-01 12:34:56" + - datetime.datetime.set: + id: datetime_test_datetime + datetime: !lambda "return {.second = 56, .minute = 34, .hour = 12, .day_of_month = 1, .month = 1, .year = 2021};" + datetime: + - platform: template + name: Datetime Test Date + id: datetime_test_date + type: date + optimistic: true + - platform: template + name: Datetime Test Time + id: datetime_test_time + type: time + optimistic: true + - platform: template + name: Datetime Test DateTime + id: datetime_test_datetime + type: datetime + optimistic: true time: From aaed14d0180b69af0c93b872688b38cd3ad9ed50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:22:20 +0100 Subject: [PATCH 381/759] [media_player] Use register_apply_action for the media player actions (#19514) --- esphome/components/media_player/__init__.py | 96 +++++++------------ esphome/components/media_player/automation.h | 70 -------------- tests/components/media_player/common.yaml | 22 +++-- .../media_player/test.esp32-idf.yaml | 9 ++ 4 files changed, 58 insertions(+), 139 deletions(-) create mode 100644 tests/components/media_player/test.esp32-idf.yaml diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index aa1e88dca95..ed3eaf48537 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -152,7 +152,7 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_ANNOUNCEMENT = "on_announcement" CONF_MEDIA_URL = "media_url" -# Command actions that all share the same schema and codegen handler +# Command actions that all share the same schema and only differ in the command sent _COMMAND_ACTIONS = [ "play", "pause", @@ -201,16 +201,7 @@ _STATE_CONDITIONS = [ "muted", ] -# Special action classes with custom schemas/handlers -PlayMediaAction = media_player_ns.class_( - "PlayMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -EnqueueMediaAction = media_player_ns.class_( - "EnqueueMediaAction", automation.Action, cg.Parented.template(MediaPlayer) -) -VolumeSetAction = media_player_ns.class_( - "VolumeSetAction", automation.Action, cg.Parented.template(MediaPlayer) -) +MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) _CALLBACK_AUTOMATIONS = ( @@ -329,59 +320,50 @@ _MEDIA_URL_ACTION_SCHEMA = cv.maybe_simple_value( ) -async def _media_action_handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - media_url = await cg.templatable(config[CONF_MEDIA_URL], args, cg.std_string) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_media_url(media_url)) - cg.add(var.set_announcement(announcement)) - return var +_ANNOUNCEMENT_FIELD = automation.ApplyField( + CONF_ANNOUNCEMENT, "set_announcement", cg.bool_ +) +_MEDIA_URL_FIELD = automation.ApplyField(CONF_MEDIA_URL, "set_media_url", cg.std_string) -automation.register_action( +def _set_command(command_name: str) -> automation.ApplyCall: + command = getattr( + MediaPlayerCommand, f"MEDIA_PLAYER_COMMAND_{command_name.upper()}" + ) + return automation.ApplyCall(f"set_command({command})") + + +automation.register_apply_action( "media_player.play_media", - PlayMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) -automation.register_action( +automation.register_apply_action( "media_player.enqueue", - EnqueueMediaAction, _MEDIA_URL_ACTION_SCHEMA, - synchronous=True, -)(_media_action_handler) + _set_command("enqueue"), + _MEDIA_URL_FIELD, + _ANNOUNCEMENT_FIELD, + call="make_call", +) + +for _action_name in _COMMAND_ACTIONS: + automation.register_apply_action( + f"media_player.{_action_name}", + MEDIA_PLAYER_ACTION_SCHEMA, + _set_command(_action_name), + _ANNOUNCEMENT_FIELD, + call="make_call", + ) def _snake_to_camel(name): return "".join(word.capitalize() for word in name.split("_")) -def _register_command_actions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) - cg.add(var.set_announcement(announcement)) - return var - - for action_name in _COMMAND_ACTIONS: - class_name = f"{_snake_to_camel(action_name)}Action" - action_class = media_player_ns.class_( - class_name, automation.Action, cg.Parented.template(MediaPlayer) - ) - automation.register_action( - f"media_player.{action_name}", - action_class, - MEDIA_PLAYER_ACTION_SCHEMA, - synchronous=True, - )(handler) - - -_register_command_actions() - - def _register_state_conditions(): async def handler(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -401,9 +383,8 @@ def _register_state_conditions(): _register_state_conditions() -@automation.register_action( +automation.register_apply_action( "media_player.volume_set", - VolumeSetAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(MediaPlayer), @@ -411,14 +392,9 @@ _register_state_conditions() }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), + call="make_call", ) -async def media_player_volume_set_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - volume = await cg.templatable(config[CONF_VOLUME], args, cg.float_) - cg.add(var.set_volume(volume)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 899acfefdfb..648e9eb29f3 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -5,76 +5,6 @@ namespace esphome::media_player { -template -class MediaPlayerCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, announcement); - void play(const Ts &...x) override { - this->parent_->make_call().set_command(Command).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayAction = MediaPlayerCommandAction; -template -using PauseAction = MediaPlayerCommandAction; -template -using StopAction = MediaPlayerCommandAction; -template -using ToggleAction = MediaPlayerCommandAction; -template -using VolumeUpAction = MediaPlayerCommandAction; -template -using VolumeDownAction = MediaPlayerCommandAction; -template -using TurnOnAction = MediaPlayerCommandAction; -template -using TurnOffAction = MediaPlayerCommandAction; -template -using NextAction = MediaPlayerCommandAction; -template -using PreviousAction = MediaPlayerCommandAction; -template -using MuteAction = MediaPlayerCommandAction; -template -using UnmuteAction = MediaPlayerCommandAction; -template -using RepeatOffAction = MediaPlayerCommandAction; -template -using RepeatOneAction = MediaPlayerCommandAction; -template -using RepeatAllAction = MediaPlayerCommandAction; -template -using ShuffleAction = MediaPlayerCommandAction; -template -using UnshuffleAction = MediaPlayerCommandAction; -template -using GroupJoinAction = MediaPlayerCommandAction; -template -using ClearPlaylistAction = MediaPlayerCommandAction; - -template -class MediaPlayerMediaAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, media_url) - TEMPLATABLE_VALUE(bool, announcement) - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - if constexpr (Command != MediaPlayerCommand::MEDIA_PLAYER_COMMAND_PLAY) - call.set_command(Command); - call.set_media_url(this->media_url_.value(x...)).set_announcement(this->announcement_.value(x...)).perform(); - } -}; - -template -using PlayMediaAction = MediaPlayerMediaAction; -template -using EnqueueMediaAction = MediaPlayerMediaAction; - -template class VolumeSetAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, volume) - void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } -}; - /// Callback forwarder that triggers an Automation<> on any state change. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. struct StateAnyForwarder { diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index 88d04d0ff06..dd6773864d6 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -1,28 +1,30 @@ -i2s_audio: - i2s_lrclk_pin: 13 - i2s_bclk_pin: 14 - i2s_mclk_pin: 15 - speaker: - platform: i2s_audio id: test_speaker - i2s_dout_pin: 18 + i2s_audio_id: i2s_audio_bus + i2s_dout_pin: ${i2s_dout_pin} dac_type: external media_player: - platform: speaker + id: test_media_player name: None - speaker: test_speaker + announcement_pipeline: + speaker: test_speaker + format: NONE on_state: - media_player.play: - media_player.play_media: http://localhost/media.mp3 - - media_player.play_media: !lambda 'return "http://localhost/media.mp3";' + - media_player.play_media: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; on_idle: - media_player.pause: on_play: - media_player.stop: - media_player.stop: announcement: true + - media_player.stop: + announcement: !lambda 'return id(test_media_player).is_muted();' on_announcement: - media_player.play: on_turn_on: @@ -61,8 +63,10 @@ media_player: - media_player.volume_up: - media_player.volume_down: - media_player.volume_set: 50% + - media_player.volume_set: !lambda 'return id(test_media_player).volume / 2;' - media_player.enqueue: http://localhost/media.mp3 - - media_player.enqueue: !lambda 'return "http://localhost/media.mp3";' + - media_player.enqueue: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; - media_player.enqueue: media_url: http://localhost/media.mp3 announcement: true diff --git a/tests/components/media_player/test.esp32-idf.yaml b/tests/components/media_player/test.esp32-idf.yaml new file mode 100644 index 00000000000..3e55bfb742f --- /dev/null +++ b/tests/components/media_player/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_dout_pin: GPIO18 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + media_player: !include common.yaml + +wifi: + ap: From 7df946b66575f56c27ba7b10c0b17225dfff4065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:22:44 +0100 Subject: [PATCH 382/759] [ledc] Use register_apply_action for the set_frequency action (#19515) --- esphome/components/ledc/ledc_output.h | 15 --------------- esphome/components/ledc/output.py | 19 ++----------------- tests/components/ledc/common.yaml | 3 +++ 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b0a243f2e43..de63325455b 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #include @@ -43,20 +42,6 @@ class LEDCOutput final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LEDCOutput *parent_; -}; - } // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index e5e7c3dcbe4..bb7f8de3fe8 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -12,8 +12,6 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp32"] @@ -45,7 +43,6 @@ def validate_frequency(value: Any) -> float: ledc_ns = cg.esphome_ns.namespace("ledc") LEDCOutput = ledc_ns.class_("LEDCOutput", output.FloatOutput, cg.Component) -SetFrequencyAction = ledc_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -77,25 +74,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_phase_angle(config[CONF_PHASE_ANGLE])) -@automation.register_action( +automation.register_apply_action( "output.ledc.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LEDCOutput), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def ledc_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/tests/components/ledc/common.yaml b/tests/components/ledc/common.yaml index 70352b45195..52bef63fbb6 100644 --- a/tests/components/ledc/common.yaml +++ b/tests/components/ledc/common.yaml @@ -4,6 +4,9 @@ esphome: - output.ledc.set_frequency: id: test_ledc frequency: 100Hz + - output.ledc.set_frequency: + id: test_ledc + frequency: !lambda return 200.0f; output: - platform: ledc From 802f2b9f24f9ab25841ed295932a8db27db1bef2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:20 +0100 Subject: [PATCH 383/759] [libretiny_pwm] Use register_apply_action for the set_frequency action (#19518) --- .../components/libretiny_pwm/libretiny_pwm.h | 15 --------------- esphome/components/libretiny_pwm/output.py | 19 ++----------------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f0ea0228b7d..0b3d55f6d2a 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" #ifdef USE_LIBRETINY @@ -34,20 +33,6 @@ class LibreTinyPWM final : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - protected: - LibreTinyPWM *parent_; -}; - } // namespace esphome::libretiny_pwm #endif diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 716ccfad2b2..e8be88df60f 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,15 +3,12 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] libretinypwm_ns = cg.esphome_ns.namespace("libretiny_pwm") LibreTinyPWM = libretinypwm_ns.class_("LibreTinyPWM", output.FloatOutput, cg.Component) -SetFrequencyAction = libretinypwm_ns.class_("SetFrequencyAction", automation.Action) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -32,25 +29,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.libretiny_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(LibreTinyPWM), cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def libretiny_pwm_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var From 07c8cfdde3039b76152d51403e9859f19f015590 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:22 +0100 Subject: [PATCH 384/759] [cc1101] Use register_apply_action for the setter actions (#19472) --- esphome/components/cc1101/__init__.py | 114 ++++++++------------------ esphome/components/cc1101/cc1101.h | 79 ------------------ 2 files changed, 32 insertions(+), 161 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 01e3ed0cd54..dbafae4ad0f 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -424,91 +424,41 @@ async def send_packet_action_to_code(config, action_id, template_arg, args): return var -# Setter action definitions: (setter_name, validator, template_type, enum_map) -_SETTER_ACTIONS = [ - ( - "set_frequency", - cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), - float, - None, - ), - ("set_output_power", cv.float_range(min=-30.0, max=11.0), float, None), - ("set_modulation_type", cv.enum(MODULATION, upper=False), Modulation, MODULATION), - ("set_symbol_rate", cv.float_range(min=600, max=500000), float, None), - ( - "set_rx_attenuation", - cv.enum(RX_ATTENUATION, upper=False), - RxAttenuation, - RX_ATTENUATION, - ), - ("set_dc_blocking_filter", cv.boolean, bool, None), - ("set_manchester", cv.boolean, bool, None), - ( - "set_filter_bandwidth", - cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), - float, - None, - ), - ( - "set_fsk_deviation", - cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), - float, - None, - ), - ("set_msk_deviation", cv.int_range(min=1, max=8), cg.uint8, None), - ("set_channel", cv.uint8_t, cg.uint8, None), - ( - "set_channel_spacing", - cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), - float, - None, - ), - ( - "set_if_frequency", - cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), - float, - None, - ), -] +_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()} + +# Each key has a set_ action validated exactly like the config option. +_SETTER_ACTIONS = ( + (CONF_FREQUENCY, cg.float_), + (CONF_OUTPUT_POWER, cg.float_), + (CONF_MODULATION_TYPE, Modulation), + (CONF_SYMBOL_RATE, cg.float_), + (CONF_RX_ATTENUATION, RxAttenuation), + (CONF_DC_BLOCKING_FILTER, cg.bool_), + (CONF_MANCHESTER, cg.bool_), + (CONF_FILTER_BANDWIDTH, cg.float_), + (CONF_FSK_DEVIATION, cg.float_), + (CONF_MSK_DEVIATION, cg.uint8), + (CONF_CHANNEL, cg.uint8), + (CONF_CHANNEL_SPACING, cg.float_), + (CONF_IF_FREQUENCY, cg.float_), +) -def _register_setter_actions(): - for setter_name, validator, templ_type, enum_map in _SETTER_ACTIONS: - class_name = ( - "".join(word.capitalize() for word in setter_name.split("_")) + "Action" +def _register_setter_actions() -> None: + for conf_key, value_type in _SETTER_ACTIONS: + automation.register_apply_action( + f"cc1101.set_{conf_key}", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_VALUE): cv.templatable( + _CONFIG_VALIDATORS[conf_key] + ), + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, f"set_{conf_key}", value_type), ) - action_cls = ns.class_( - class_name, automation.Action, cg.Parented.template(CC1101Component) - ) - schema = cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_VALUE): cv.templatable(validator), - }, - key=CONF_VALUE, - ) - - async def _setter_action_to_code( - config, - action_id, - template_arg, - args, - _setter=setter_name, - _type=templ_type, - _map=enum_map, - ): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_VALUE] - if _map and not cg.is_template(data): - data = _map[data] - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - return var - - automation.register_action( - f"cc1101.{setter_name}", action_cls, schema, synchronous=True - )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 79bfc9cb33e..1f7a55fbf14 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -174,83 +174,4 @@ template class SendPacketAction final : public Action, pu size_t data_static_len_{0}; }; -template class SetSymbolRateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, symbol_rate) - void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } -}; - -template class SetFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, frequency) - void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } -}; - -template class SetOutputPowerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, output_power) - void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } -}; - -template class SetModulationTypeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Modulation, modulation_type) - void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } -}; - -template class SetRxAttenuationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) - void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } -}; - -template -class SetDcBlockingFilterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, dc_blocking_filter) - void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } -}; - -template class SetManchesterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, manchester) - void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } -}; - -template class SetFilterBandwidthAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, filter_bandwidth) - void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } -}; - -template class SetFskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, fsk_deviation) - void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } -}; - -template class SetMskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, msk_deviation) - void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } -}; - -template class SetChannelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } -}; - -template class SetChannelSpacingAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, channel_spacing) - void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } -}; - -template class SetIfFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, if_frequency) - void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } -}; - } // namespace esphome::cc1101 From f58fe2e3ead92f55f67d860fb8a3c603ae35f0de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:30 +0100 Subject: [PATCH 385/759] [select] Use register_apply_action for the set, set_index and operation actions (#19511) --- esphome/components/select/__init__.py | 128 +++++------------- esphome/components/select/automation.h | 49 ------- tests/components/select/common.yaml | 43 ++++++ tests/components/select/test.esp32-idf.yaml | 2 + tests/components/select/test.esp8266-ard.yaml | 2 + 5 files changed, 78 insertions(+), 146 deletions(-) create mode 100644 tests/components/select/common.yaml create mode 100644 tests/components/select/test.esp32-idf.yaml create mode 100644 tests/components/select/test.esp8266-ard.yaml diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index f561c030a49..2db4c8cc34b 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -40,11 +40,6 @@ SelectStateTrigger = select_ns.class_( automation.Trigger.template(cg.StringRef, cg.size_t), ) -# Actions -SelectSetAction = select_ns.class_("SelectSetAction", automation.Action) -SelectSetIndexAction = select_ns.class_("SelectSetIndexAction", automation.Action) -SelectOperationAction = select_ns.class_("SelectOperationAction", automation.Action) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -140,40 +135,27 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "select.set", - SelectSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_OPTION, "set_option", cg.std_string), + call="make_call", ) -async def select_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_OPTION], args, cg.std_string) - cg.add(var.set_option(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "select.set_index", - SelectSetIndexAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_INDEX): cv.templatable(cv.positive_int), } ), - synchronous=True, + automation.ApplyField(CONF_INDEX, "set_index", cg.size_t), + call="make_call", ) -async def select_set_index_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.size_t) - cg.add(var.set_index(template_)) - return var @automation.register_condition( @@ -211,9 +193,8 @@ async def select_is_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg, paren, arg) -@automation.register_action( +automation.register_apply_action( "select.operation", - SelectOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -222,75 +203,28 @@ async def select_is_to_code(config, condition_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", SelectOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -@automation.register_action( - "select.next", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="NEXT"): cv.one_of("NEXT", upper=True), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.previous", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="PREVIOUS"): cv.one_of( - "PREVIOUS", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.first", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="FIRST"): cv.one_of("FIRST", upper=True), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "select.last", - SelectOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="LAST"): cv.one_of("LAST", upper=True), - } - ) - ), - synchronous=True, -) -async def select_operation_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - op_ = await cg.templatable(operation, args, SelectOperation) - cg.add(var.set_operation(op_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - SELECT_OPERATION_OPTIONS[mode], args, SelectOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var + +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("select.next", "NEXT", True), + ("select.previous", "PREVIOUS", True), + ("select.first", "FIRST", False), + ("select.last", "LAST", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({SELECT_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 8e5da893ad4..c2dc752ca5a 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -17,55 +17,6 @@ class SelectStateTrigger final : public Trigger { Select *parent_; }; -template class SelectSetAction final : public Action { - public: - explicit SelectSetAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(std::string, option) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_option(this->option_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectSetIndexAction final : public Action { - public: - explicit SelectSetIndexAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(size_t, index) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.set_index(this->index_.value(x...)); - call.perform(); - } - - protected: - Select *select_; -}; - -template class SelectOperationAction final : public Action { - public: - explicit SelectOperationAction(Select *select) : select_(select) {} - TEMPLATABLE_VALUE(bool, cycle) - TEMPLATABLE_VALUE(SelectOperation, operation) - - void play(const Ts &...x) override { - auto call = this->select_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Select *select_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/tests/components/select/common.yaml b/tests/components/select/common.yaml new file mode 100644 index 00000000000..f20c024cb8c --- /dev/null +++ b/tests/components/select/common.yaml @@ -0,0 +1,43 @@ +select: + - platform: template + name: "Test Select" + id: select_test_select + optimistic: true + options: + - one + - two + - three + initial_option: two + +button: + - platform: template + name: "Test Select Actions" + on_press: + - select.set: + id: select_test_select + option: three + - select.set: + id: select_test_select + option: !lambda |- + std::string option = "one"; + return option; + - select.set_index: + id: select_test_select + index: 1 + - select.set_index: + id: select_test_select + index: !lambda return 2; + - select.next: select_test_select + - select.previous: + id: select_test_select + cycle: false + - select.first: select_test_select + - select.last: select_test_select + - select.operation: + id: select_test_select + operation: next + cycle: false + - select.operation: + id: select_test_select + operation: !lambda return SELECT_OP_PREVIOUS; + cycle: !lambda return true; diff --git a/tests/components/select/test.esp32-idf.yaml b/tests/components/select/test.esp32-idf.yaml new file mode 100644 index 00000000000..25cb37a0b42 --- /dev/null +++ b/tests/components/select/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/select/test.esp8266-ard.yaml b/tests/components/select/test.esp8266-ard.yaml new file mode 100644 index 00000000000..25cb37a0b42 --- /dev/null +++ b/tests/components/select/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml From 2a26d5ab62e493310d9dae2d9516b154202e8970 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:23:55 +0100 Subject: [PATCH 386/759] [number] Use register_apply_action for the set and operation actions (#19510) --- esphome/components/number/__init__.py | 119 ++++++------------------- esphome/components/number/automation.h | 34 ------- tests/components/number/common.yaml | 27 ++++++ 3 files changed, 54 insertions(+), 126 deletions(-) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index fc0893323be..afa2fac483e 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -163,10 +163,6 @@ ValueRangeTrigger = number_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -# Actions -NumberSetAction = number_ns.class_("NumberSetAction", automation.Action) -NumberOperationAction = number_ns.class_("NumberOperationAction", automation.Action) - # Conditions NumberInRangeCondition = number_ns.class_( "NumberInRangeCondition", automation.Condition @@ -366,85 +362,40 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "number.set", - NumberSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), + call="make_call", ) -async def number_set_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var +# The operation is fixed by the action name; CONF_MODE only stays accepted in the config. +for _name, _mode, _cycle in ( + ("number.increment", "INCREMENT", True), + ("number.decrement", "DECREMENT", True), + ("number.to_min", "TO_MIN", False), + ("number.to_max", "TO_MAX", False), +): + _schema = {cv.Optional(CONF_MODE, default=_mode): cv.one_of(_mode, upper=True)} + _fields = [ + automation.ApplyCall(f"with_operation({NUMBER_OPERATION_OPTIONS[_mode]})") + ] + if _cycle: + _schema[cv.Optional(CONF_CYCLE, default=True)] = cv.boolean + _fields.append(automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_)) + automation.register_apply_action( + _name, + automation.maybe_simple_id(OPERATION_BASE_SCHEMA.extend(_schema)), + *_fields, + call="make_call", + ) -@automation.register_action( - "number.increment", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="INCREMENT"): cv.one_of( - "INCREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.decrement", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="DECREMENT"): cv.one_of( - "DECREMENT", upper=True - ), - cv.Optional(CONF_CYCLE, default=True): cv.boolean, - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_min", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MIN"): cv.one_of( - "TO_MIN", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( - "number.to_max", - NumberOperationAction, - automation.maybe_simple_id( - OPERATION_BASE_SCHEMA.extend( - { - cv.Optional(CONF_MODE, default="TO_MAX"): cv.one_of( - "TO_MAX", upper=True - ), - } - ) - ), - synchronous=True, -) -@automation.register_action( +automation.register_apply_action( "number.operation", - NumberOperationAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_OPERATION): cv.templatable( @@ -453,23 +404,7 @@ async def number_set_to_code(config, action_id, template_arg, args): cv.Optional(CONF_CYCLE, default=True): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_OPERATION, "with_operation", NumberOperation), + automation.ApplyField(CONF_CYCLE, "with_cycle", cg.bool_), + call="make_call", ) -async def number_to_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (operation := config.get(CONF_OPERATION)) is not None: - to_ = await cg.templatable(operation, args, NumberOperation) - cg.add(var.set_operation(to_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable( - NUMBER_OPERATION_OPTIONS[mode], args, NumberOperation - ) - cg.add(var.set_operation(template_)) - if (cycle := config.get(CONF_CYCLE)) is not None: - template_ = await cg.templatable(cycle, args, cg.bool_) - cg.add(var.set_cycle(template_)) - return var diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 4efcfd30d8e..f870acf8ddb 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -13,40 +13,6 @@ class NumberStateTrigger final : public Trigger { } }; -template class NumberSetAction final : public Action { - public: - NumberSetAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Number *number_; -}; - -template class NumberOperationAction final : public Action { - public: - explicit NumberOperationAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(NumberOperation, operation) - TEMPLATABLE_VALUE(bool, cycle) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Number *number_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/tests/components/number/common.yaml b/tests/components/number/common.yaml index b1a16ebfedd..3199dd90f89 100644 --- a/tests/components/number/common.yaml +++ b/tests/components/number/common.yaml @@ -11,3 +11,30 @@ sensor: - platform: number name: "Test Number Value" source_id: number_test_number + +button: + - platform: template + name: "Test Number Actions" + on_press: + - number.set: + id: number_test_number + value: 42 + - number.set: + id: number_test_number + value: !lambda |- + float next = id(number_test_number).state + 1; + return next; + - number.increment: number_test_number + - number.decrement: + id: number_test_number + cycle: false + - number.to_min: number_test_number + - number.to_max: number_test_number + - number.operation: + id: number_test_number + operation: increment + cycle: false + - number.operation: + id: number_test_number + operation: !lambda return NUMBER_OP_DECREMENT; + cycle: !lambda return true; From bd91d3ff239726578675e53ee3edd0eda8a495fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:12 +0100 Subject: [PATCH 387/759] [display] Use register_apply_action for display.page.show_next and show_previous (#19519) --- esphome/components/display/__init__.py | 43 ++++++++------------------ esphome/components/display/display.h | 18 ----------- tests/components/mipi_spi/common.yaml | 11 +++++++ 3 files changed, 24 insertions(+), 48 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 7a66da11f2b..b664579be93 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,12 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayPageShowNextAction = display_ns.class_( - "DisplayPageShowNextAction", automation.Action -) -DisplayPageShowPrevAction = display_ns.class_( - "DisplayPageShowPrevAction", automation.Action -) DisplayIsDisplayingPageCondition = display_ns.class_( "DisplayIsDisplayingPageCondition", automation.Condition ) @@ -288,34 +282,23 @@ async def display_page_show_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +# The id is the display itself, so it cannot be a lambda; a plain use_id rejects one clearly. +DISPLAY_PAGE_CYCLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(Display), + } +) + +automation.register_apply_action( "display.page.show_next", - DisplayPageShowNextAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_next_page()"), ) -async def display_page_show_next_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( +automation.register_apply_action( "display.page.show_previous", - DisplayPageShowPrevAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_prev_page()"), ) -async def display_page_show_previous_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition( diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index c1389721496..af5a7bc1324 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -848,24 +848,6 @@ template class DisplayPageShowAction final : public Action class DisplayPageShowNextAction final : public Action { - public: - DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_next_page(); } - - Display *buffer_; -}; - -template class DisplayPageShowPrevAction final : public Action { - public: - DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} - - void play(const Ts &...x) override { this->buffer_->show_prev_page(); } - - Display *buffer_; -}; - template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index a867b726edd..6bdee9e54a6 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -1,3 +1,10 @@ +esphome: + on_boot: + then: + - display.page.show_next: page_display + - display.page.show_previous: + id: page_display + display: - platform: mipi_spi spi_16: true @@ -32,7 +39,11 @@ display: bus_mode: single - platform: mipi_spi + id: page_display model: WAVESHARE-1.83-V2 + pages: + - lambda: it.rectangle(0, 0, 10, 10); + - lambda: it.rectangle(10, 10, 10, 10); dc_pin: allow_other_uses: true number: ${dc_pin} From 4efeee245c025577c9a5442b6fb72247930fbac8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:25 +0100 Subject: [PATCH 388/759] [text] Use register_apply_action for text.set (#19509) --- esphome/components/text/__init__.py | 24 +++++------------------- esphome/components/text/automation.h | 15 --------------- tests/components/text/common.yaml | 11 +++++++++++ 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index e010e2c2922..2f82f19ff54 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -13,13 +13,13 @@ from esphome.const import ( CONF_VALUE, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] @@ -34,9 +34,6 @@ TextStateTrigger = text_ns.class_( "TextStateTrigger", automation.Trigger.template(cg.std_string) ) -# Actions -TextSetAction = text_ns.class_("TextSetAction", automation.Action) - # Conditions TextMode = text_ns.enum("TextMode") @@ -160,24 +157,13 @@ OPERATION_BASE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "text.set", - TextSetAction, OPERATION_BASE_SCHEMA.extend( { cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.std_string), + call="make_call", ) -async def text_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.std_string) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index 916d86340d4..bd6fb3cd23c 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -13,19 +13,4 @@ class TextStateTrigger final : public Trigger { } }; -template class TextSetAction final : public Action { - public: - explicit TextSetAction(Text *text) : text_(text) {} - TEMPLATABLE_VALUE(std::string, value) - - void play(const Ts &...x) override { - auto call = this->text_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Text *text_; -}; - } // namespace esphome::text diff --git a/tests/components/text/common.yaml b/tests/components/text/common.yaml index 561d17143f4..3110b60fa69 100644 --- a/tests/components/text/common.yaml +++ b/tests/components/text/common.yaml @@ -28,3 +28,14 @@ text_sensor: - platform: text name: "Test Text State" source_id: test_text + +button: + - platform: template + name: Set Text + on_press: + - text.set: + id: test_text + value: Hello + - text.set: + id: test_text + value: !lambda return id(test_text).state + "!"; From c275365258b87d0cb17cd9f3a718d1f961f6fcb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:44 +0100 Subject: [PATCH 389/759] [rp2040_pwm] Use register_apply_action for the set_frequency action (#19517) --- esphome/components/rp2040_pwm/output.py | 19 ++----------------- esphome/components/rp2040_pwm/rp2040_pwm.h | 14 -------------- tests/components/rp2040_pwm/common.yaml | 10 ++++++++++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a0344e80542..49b10f7d201 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -13,7 +11,6 @@ DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( @@ -36,25 +33,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(config[CONF_FREQUENCY])) -@automation.register_action( +automation.register_apply_action( "output.rp2040_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RP2040PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def rp2040_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 8263113168d..7808e2af01c 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -3,7 +3,6 @@ #ifdef USE_RP2 #include "esphome/components/output/float_output.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -39,19 +38,6 @@ class RP2040PWM final : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - RP2040PWM *parent_; -}; - } // namespace esphome::rp2040_pwm #endif // USE_RP2 diff --git a/tests/components/rp2040_pwm/common.yaml b/tests/components/rp2040_pwm/common.yaml index 2970a48afbe..b857a7e76fb 100644 --- a/tests/components/rp2040_pwm/common.yaml +++ b/tests/components/rp2040_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_1 + frequency: 100Hz + - output.rp2040_pwm.set_frequency: + id: rp2040_pwm_light_output_2 + frequency: !lambda return 200.0f; + output: - platform: rp2040_pwm id: rp2040_pwm_light_output_1 From 6a97172867ddd10bdb7195948274a4703aa5c392 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:24:56 +0100 Subject: [PATCH 390/759] [stepper] Use register_apply_action for the setter actions (#19470) --- esphome/components/stepper/__init__.py | 142 ++++++++----------------- esphome/components/stepper/stepper.h | 71 ------------- tests/components/stepper/common.yaml | 9 ++ 3 files changed, 56 insertions(+), 166 deletions(-) diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 8e801876628..017fd95cd89 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv @@ -11,18 +14,13 @@ from esphome.const import ( CONF_TARGET, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import SafeExpType IS_PLATFORM_COMPONENT = True stepper_ns = cg.esphome_ns.namespace("stepper") Stepper = stepper_ns.class_("Stepper") -SetTargetAction = stepper_ns.class_("SetTargetAction", automation.Action) -ReportPositionAction = stepper_ns.class_("ReportPositionAction", automation.Action) -SetSpeedAction = stepper_ns.class_("SetSpeedAction", automation.Action) -SetAccelerationAction = stepper_ns.class_("SetAccelerationAction", automation.Action) -SetDecelerationAction = stepper_ns.class_("SetDecelerationAction", automation.Action) - def validate_acceleration(value): value = cv.string(value) @@ -90,99 +88,53 @@ async def register_stepper(var, config): await setup_stepper_core_(var, config) -@automation.register_action( - "stepper.set_target", - SetTargetAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_TARGET): cv.templatable(cv.int_), - } - ), - synchronous=True, +def _register_stepper_action( + name: str, + key: str, + validator: Callable[[Any], Any], + target: str, + type_: SafeExpType, + *extra: automation.ApplyCall, +) -> None: + automation.register_apply_action( + f"stepper.{name}", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Stepper), + cv.Required(key): cv.templatable(validator), + } + ), + automation.ApplyField(key, target, type_), + *extra, + ) + + +_register_stepper_action("set_target", CONF_TARGET, cv.int_, "set_target", cg.int32) +_register_stepper_action( + "report_position", CONF_POSITION, cv.int_, "report_position", cg.int32 ) -async def stepper_set_target_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TARGET], args, cg.int32) - cg.add(var.set_target(template_)) - return var - - -@automation.register_action( - "stepper.report_position", - ReportPositionAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_POSITION): cv.templatable(cv.int_), - } - ), - synchronous=True, +_register_stepper_action( + "set_speed", + CONF_SPEED, + validate_speed, + "set_max_speed", + cg.float_, + automation.ApplyCall("on_update_speed()"), ) -async def stepper_report_position_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_POSITION], args, cg.int32) - cg.add(var.set_position(template_)) - return var - - -@automation.register_action( - "stepper.set_speed", - SetSpeedAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_SPEED): cv.templatable(validate_speed), - } - ), - synchronous=True, +_register_stepper_action( + "set_acceleration", + CONF_ACCELERATION, + validate_acceleration, + "set_acceleration", + cg.float_, ) -async def stepper_set_speed_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_SPEED], args, cg.float_) - cg.add(var.set_speed(template_)) - return var - - -@automation.register_action( - "stepper.set_acceleration", - SetAccelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_ACCELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, +_register_stepper_action( + "set_deceleration", + CONF_DECELERATION, + validate_acceleration, + "set_deceleration", + cg.float_, ) -async def stepper_set_acceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_ACCELERATION], args, cg.float_) - cg.add(var.set_acceleration(template_)) - return var - - -@automation.register_action( - "stepper.set_deceleration", - SetDecelerationAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(Stepper), - cv.Required(CONF_DECELERATION): cv.templatable(validate_acceleration), - } - ), - synchronous=True, -) -async def stepper_set_deceleration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_DECELERATION], args, cg.float_) - cg.add(var.set_deceleration(template_)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 06ef3bab375..8a8fbcd896c 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" namespace esphome::stepper { @@ -37,74 +36,4 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction final : public Action { - public: - explicit SetTargetAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, target) - - void play(const Ts &...x) override { this->parent_->set_target(this->target_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class ReportPositionAction final : public Action { - public: - explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(int32_t, position) - - void play(const Ts &...x) override { this->parent_->report_position(this->position_.value(x...)); } - - protected: - Stepper *parent_; -}; - -template class SetSpeedAction final : public Action { - public: - explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, speed); - - void play(const Ts &...x) override { - float speed = this->speed_.value(x...); - this->parent_->set_max_speed(speed); - this->parent_->on_update_speed(); - } - - protected: - Stepper *parent_; -}; - -template class SetAccelerationAction final : public Action { - public: - explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, acceleration); - - void play(const Ts &...x) override { - float acceleration = this->acceleration_.value(x...); - this->parent_->set_acceleration(acceleration); - } - - protected: - Stepper *parent_; -}; - -template class SetDecelerationAction final : public Action { - public: - explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(float, deceleration); - - void play(const Ts &...x) override { - float deceleration = this->deceleration_.value(x...); - this->parent_->set_deceleration(deceleration); - } - - protected: - Stepper *parent_; -}; - } // namespace esphome::stepper diff --git a/tests/components/stepper/common.yaml b/tests/components/stepper/common.yaml index fcf57596188..ba70fb7979b 100644 --- a/tests/components/stepper/common.yaml +++ b/tests/components/stepper/common.yaml @@ -25,3 +25,12 @@ switch: - stepper.report_position: id: test_stepper position: 0 + - stepper.set_speed: + id: test_stepper + speed: 300 steps/s + - stepper.set_acceleration: + id: test_stepper + acceleration: !lambda return 150.0f; + - stepper.set_deceleration: + id: test_stepper + deceleration: 250 steps/s^2 From 22efbd9778a446e3324f136bdaa9bf2ebc2f45e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:04 +0100 Subject: [PATCH 391/759] [esp32_ble_tracker] Use register_apply_action for start_scan and stop_scan (#19520) --- .../components/esp32_ble_tracker/__init__.py | 40 +++---------------- .../components/esp32_ble_tracker/automation.h | 24 ----------- .../esp32_ble_tracker/esp32_ble_tracker.h | 6 +++ 3 files changed, 12 insertions(+), 58 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 906144e5fde..205acc55a53 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -39,8 +39,7 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority from esphome.enum import StrEnum from esphome.types import ConfigType @@ -116,12 +115,6 @@ BLEEndOfScanTrigger = esp32_ble_tracker_ns.class_( "BLEEndOfScanTrigger", automation.Trigger.template() ) # Actions -ESP32BLEStartScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStartScanAction", automation.Action -) -ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( - "ESP32BLEStopScanAction", automation.Action -) def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: @@ -469,23 +462,12 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.start_scan", - ESP32BLEStartScanAction, ESP32_BLE_START_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_CONTINUOUS, "set_scan_continuous", cg.bool_), + automation.ApplyCall("start_scan_if_idle()"), ) -async def esp32_ble_tracker_start_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) - cg.add(var.set_continuous(template_)) - return var ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( @@ -497,21 +479,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "esp32_ble_tracker.stop_scan", - ESP32BLEStopScanAction, ESP32_BLE_STOP_SCAN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("stop_scan()"), ) -async def esp32_ble_tracker_stop_scan_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def register_ble_device( diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 541b63b2fdb..9b4eb2c3b30 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -94,30 +94,6 @@ class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction final : public Action { - public: - ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(bool, continuous) - void play(const Ts &...x) override { - this->parent_->set_scan_continuous(this->continuous_.value(x...)); - // Only call start_scan() if scanner is IDLE - // For other states (STARTING, RUNNING, STOPPING, FAILED), the normal state - // machine flow will eventually transition back to IDLE, at which point - // loop() will see scan_continuous_ and restart scanning if it is true. - if (this->parent_->get_scanner_state() == ScannerState::IDLE) { - this->parent_->start_scan(); - } - } - - protected: - ESP32BLETracker *parent_; -}; - -template class ESP32BLEStopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::esp32_ble_tracker #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index aa470983df9..6b71480ed3e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -218,6 +218,12 @@ class ESP32BLETracker final : public Component, #endif void start_scan(); + // For the start_scan action: in any other state the state machine returns to IDLE on its own + // and loop() restarts scanning when scan_continuous_ is set, so only an idle scanner starts here. + void start_scan_if_idle() { + if (this->scanner_state_ == ScannerState::IDLE) + this->start_scan(); + } void stop_scan(); void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); From a47271b1f1c03a818251dd7fafb181cb81bcb9a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:33 +0100 Subject: [PATCH 392/759] [fan] Use register_apply_action for fan.turn_on (#19473) --- esphome/components/fan/__init__.py | 55 ++++------------------------- esphome/components/fan/automation.h | 27 -------------- tests/components/fan/common.yaml | 2 +- 3 files changed, 7 insertions(+), 77 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index 3949f16d2e1..d53357b7c17 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -31,19 +31,17 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) -FanCall = fan_ns.class_("FanCall") FanDirection = fan_ns.enum("FanDirection", is_class=True) FAN_DIRECTION_ENUM = { @@ -63,7 +61,6 @@ RESTORE_MODES = { } # Actions -TurnOnAction = fan_ns.class_("TurnOnAction", automation.Action) TurnOffAction = fan_ns.class_("TurnOffAction", automation.Action) ToggleAction = fan_ns.class_("ToggleAction", automation.Action) CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) @@ -332,9 +329,8 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_apply_action( "fan.turn_on", - TurnOnAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Fan), @@ -345,50 +341,11 @@ async def fan_turn_off_to_code(config, action_id, template_arg, args): ), } ), - synchronous=True, + automation.ApplyField(CONF_OSCILLATING, "set_oscillating", cg.bool_), + automation.ApplyField(CONF_SPEED, "set_speed", cg.int_), + automation.ApplyField(CONF_DIRECTION, "set_direction", FanDirection), + call="turn_on", ) -async def fan_turn_on_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_OSCILLATING, "set_oscillating", cg.bool_), - (CONF_SPEED, "set_speed", cg.int_), - (CONF_DIRECTION, "set_direction", FanDirection), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (FanCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @automation.register_action( diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index cbd994e749a..f62a2dd8cde 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,33 +6,6 @@ namespace esphome::fan { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `speed: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction final : public Action { - public: - using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); - TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->state_->turn_on(); - this->apply_(call, x...); - call.perform(); - } - - Fan *state_; - ApplyFn apply_; -}; - template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 76508f391e2..3bef83cc42a 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -67,7 +67,7 @@ binary_sensor: return false; # Exercise fan.turn_on with various field combinations so the -# TurnOnAction codegen paths get build coverage. +# register_apply_action codegen paths get build coverage. button: - platform: template name: "Fan Speed Only" From 281c54bcb88195d5e0bc15ce92bf85baec206dd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:25:42 +0100 Subject: [PATCH 393/759] [globals] Use register_apply_action for globals.set (#19481) --- esphome/components/globals/__init__.py | 34 +++++-------------- .../components/globals/globals_component.h | 18 ++-------- tests/components/globals/common.yaml | 4 +-- 3 files changed, 14 insertions(+), 42 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index bd6bc5f783a..8adf4e983ca 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,8 +8,7 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -21,7 +20,6 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) -GlobalVarSetAction = globals_ns.class_("GlobalVarSetAction", automation.Action) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -94,33 +92,19 @@ async def to_code(config: ConfigType) -> None: cg.add(glob.set_name_hash(hash_)) -@automation.register_action( +# A lambda returns the global's own type so `return 42;` reduces to a cast; the value +# itself is raw C++ text. +_VALUE_TYPE = "std::remove_reference_tvalue())>" + +automation.register_apply_action( "globals.set", - GlobalVarSetAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GlobalsComponent), cv.Required(CONF_VALUE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField( + CONF_VALUE, "value() = {}", _VALUE_TYPE, const_fn=lambda _, value: value + ), ) -async def globals_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - var = cg.new_Pvariable(action_id, template_arg, paren) - # Use the global's value_type alias as the lambda return type so - # TemplatableFn stores a direct function pointer instead of going through - # the deprecated converting trampoline when the value expression deduces - # to a different type (e.g. int literal assigned to a float global). - value_type = cg.RawExpression(f"{full_id.type}::value_type") - templ = await cg.templatable( - config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression - ) - cg.add(var.set_value(templ)) - return var diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 78d2bc5910b..b7acb065695 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -1,8 +1,10 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include #include namespace esphome::globals { @@ -127,20 +129,6 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction final : public Action { - public: - explicit GlobalVarSetAction(C *parent) : parent_(parent) {} - - using T = typename C::value_type; - - TEMPLATABLE_VALUE(T, value); - - void play(const Ts &...x) override { this->parent_->value() = this->value_.value(x...); } - - protected: - C *parent_; -}; - template T &id(GlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalStringComponent *value) { return value->value(); } diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 6d5721d3be8..bff7de8b7f6 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,8 +4,8 @@ esphome: - globals.set: id: glob_int value: "10" - # Set a float global with an integer literal - must emit the correct - # return type so TemplatableFn stores a direct function pointer. + # Set a float global with an integer literal; the lambda must return the + # global's own type so the assignment needs no conversion. - globals.set: id: glob_float value: "102" From c1d82168d8e3d3977b79fa83844703be277b0b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:26:20 +0100 Subject: [PATCH 394/759] [event] Use register_apply_action for event.trigger (#19508) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/event/__init__.py | 23 ++++++----------------- esphome/components/event/automation.h | 7 ------- tests/components/event/common.yaml | 12 ++++++++++++ 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 881107b713a..f6c30947244 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,14 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@nohat"] @@ -40,8 +40,6 @@ event_ns = cg.esphome_ns.namespace("event") Event = event_ns.class_("Event", cg.EntityBase) EventPtr = Event.operator("ptr") -TriggerEventAction = event_ns.class_("TriggerEventAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _EVENT_SCHEMA = ( @@ -135,20 +133,11 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) -@automation.register_action( - "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True +automation.register_apply_action( + "event.trigger", + TRIGGER_EVENT_SCHEMA, + automation.ApplyField(CONF_EVENT_TYPE, "trigger", cg.std_string), ) -async def event_fire_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) - cg.add(var.set_event_type(templ)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 73a6336f780..ee1d1a13c49 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,13 +6,6 @@ namespace esphome::event { -template class TriggerEventAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, event_type) - - void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } -}; - class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { diff --git a/tests/components/event/common.yaml b/tests/components/event/common.yaml index 555d049c705..827f1ebb529 100644 --- a/tests/components/event/common.yaml +++ b/tests/components/event/common.yaml @@ -18,3 +18,15 @@ event: // Log using %.*s format for StringRef ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str()); } + +button: + - platform: template + name: Trigger Event + on_press: + - event.trigger: + id: some_event + event_type: template_event_type1 + - event.trigger: + id: some_event + event_type: !lambda |- + return id(some_event).has_event() ? "template_event_type2" : "template_event_type1"; From 2ddc6a074196969d87297a508f669d80011575cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:26:41 +0100 Subject: [PATCH 395/759] [sensor] Use register_apply_action for sensor.template.publish (#19507) --- esphome/components/sensor/__init__.py | 1 - esphome/components/sensor/automation.h | 11 ----------- esphome/components/template/sensor/__init__.py | 11 ++--------- tests/components/sensor/common.yaml | 11 +++++++++++ 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 3b632a1847f..62d78a32dd7 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -256,7 +256,6 @@ SensorPtr = Sensor.operator("ptr") ValueRangeTrigger = sensor_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) -SensorPublishAction = sensor_ns.class_("SensorPublishAction", automation.Action) # Filters Filter = sensor_ns.class_("Filter") diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 35a4a29e0d2..08210634341 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -20,17 +20,6 @@ class SensorRawStateTrigger final : public Trigger { } }; -template class SensorPublishAction final : public Action { - public: - SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(float, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - Sensor *sensor_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 55537a56369..f831f87d92b 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -49,20 +49,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "sensor.template.publish", - sensor.SensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(sensor.Sensor), cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.float_), ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 1961c986853..b8f51835881 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -5,6 +5,17 @@ sensor: id: source_sensor lambda: return 42.0; update_interval: 1s + on_value: + - sensor.template.publish: + id: publish_target + state: 42.0 + - sensor.template.publish: + id: publish_target + state: !lambda "return x * 2.0;" + + - platform: template + name: "Publish Target" + id: publish_target # Streaming filters (window_size == send_every) - uses StreamingFilter base class - platform: copy From 20703f32bb72fb3978c08eef5276d2f2c34d100d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:01 +0100 Subject: [PATCH 396/759] [mitsubishi_cn105] Use register_apply_action for vane.control (#19477) --- .../components/mitsubishi_cn105/__init__.py | 65 +++---------------- .../components/mitsubishi_cn105/automation.h | 19 ------ tests/components/mitsubishi_cn105/common.yaml | 2 +- .../mitsubishi_cn105_component_tests.cpp | 8 --- 4 files changed, 10 insertions(+), 84 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 470b7be5fc8..9502773616e 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -10,8 +10,8 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_USE_FAHRENHEIT, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj +from esphome.core import ID +from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@crnjan"] @@ -32,7 +32,6 @@ MitsubishiCN105Component = mitsubishi_ns.class_( ) VaneState = mitsubishi_ns.struct("VaneState") -VaneCall = mitsubishi_ns.class_("VaneCall") VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") # The insertion order must match VALUES in @@ -59,10 +58,6 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) -VaneControlAction = mitsubishi_ns.class_( - "VaneControlAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -117,8 +112,9 @@ async def to_code(config: ConfigType) -> None: ) ) cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) + # User lambdas may use unqualified enum names. + cg.add_global(mitsubishi_ns.using) if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): - cg.add_global(mitsubishi_ns.using) for conf in on_state: await automation.build_callback_automation( var, @@ -183,14 +179,6 @@ async def clear_temperature_action_to_code( return var -VANE_CONTROL_FIELDS = ( - ( - (CONF_VERTICAL, CONF_DIRECTION), - "vertical.set_direction", - VerticalVaneMode, - ), -) - VANE_CONTROL_ACTION_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), @@ -205,46 +193,11 @@ VANE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.vane.control", - VaneControlAction, VANE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + (CONF_VERTICAL, CONF_DIRECTION), "vertical.set_direction", VerticalVaneMode + ), + call="make_vane_call", ) -async def vane_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - cg.add_global(mitsubishi_ns.using) - parent = await cg.get_variable(config[CONF_ID]) - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) - for t, name in args - ] - forwarded_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for path, setter, type_ in VANE_CONTROL_FIELDS: - if (section := config.get(path[0])) is None: - continue - if (value := section.get(path[1])) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, - normalized_args, - return_type=type_, - ) - body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - [(VaneCall.operator("ref"), "call"), *normalized_args], - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h index f9ca3a47e63..f0bd771fd5c 100644 --- a/esphome/components/mitsubishi_cn105/automation.h +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -4,8 +4,6 @@ #include "esphome/core/automation.h" -#include - namespace esphome::mitsubishi_cn105 { template @@ -22,21 +20,4 @@ class ClearRemoteTemperatureAction final : public Action, public Parented void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; -template class VaneControlAction final : public Action { - public: - using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); - - VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_vane_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - MitsubishiCN105Component *parent_; - ApplyFn apply_; -}; - } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 3f7e8c8f954..eb5b55aa068 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -37,4 +37,4 @@ esphome: - mitsubishi_cn105.vane.control: id: ac vertical: - direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; + direction: !lambda return VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp index 928d4123f61..17cb1cb8c73 100644 --- a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -63,12 +63,4 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); } -TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { - MitsubishiCN105Component hub; - VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); - - action.play(); - - EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); -} } // namespace esphome::mitsubishi_cn105::testing From 21dc6af22e3b9b5739ba502f05fde516163d0ad3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:29 +0100 Subject: [PATCH 397/759] [binary_sensor] Use register_apply_action for binary_sensor.template.publish (#19506) --- esphome/components/binary_sensor/__init__.py | 3 --- esphome/components/binary_sensor/automation.h | 14 -------------- .../components/template/binary_sensor/__init__.py | 11 ++--------- tests/components/binary_sensor/common.yaml | 10 ++++++++++ 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a114ab42051..001a055d7cf 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -134,9 +134,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorPublishAction = binary_sensor_ns.class_( - "BinarySensorPublishAction", automation.Action -) BinarySensorInvalidateAction = binary_sensor_ns.class_( "BinarySensorInvalidateAction", automation.Action ) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index d5a85ca9c42..1c331d22bdf 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -165,20 +165,6 @@ template class BinarySensorCondition final : public Condition class BinarySensorPublishAction final : public Action { - public: - explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto val = this->state_.value(x...); - this->sensor_->publish_state(val); - } - - protected: - BinarySensor *sensor_; -}; - template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 07028f7dffc..b2d6eca260c 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -60,20 +60,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.template.publish", - binary_sensor.BinarySensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index d0a16cc99c8..f9eda80c3fd 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -37,6 +37,16 @@ binary_sensor: format: "New state is %s" args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor + - binary_sensor.template.publish: + id: publish_target + state: true + - binary_sensor.template.publish: + id: publish_target + state: !lambda "return x.value_or(false);" + + - platform: template + id: publish_target + name: "Publish Target" # Test autorepeat with default configuration (no timings) - platform: template From 1be1f675162824586f3c72f8854dbf9796a469e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:27:34 +0100 Subject: [PATCH 398/759] [climate] Use register_apply_action for climate.control (#19475) --- esphome/components/climate/__init__.py | 111 ++++++++---------------- esphome/components/climate/automation.h | 28 ------ tests/components/climate/common.yaml | 20 ++++- 3 files changed, 52 insertions(+), 107 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 3fbca1a6d03..4c9f58c7a40 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -50,18 +50,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import ( - LambdaExpression, - MockObj, - MockObjClass, - TemplateArgsType, -) +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -169,7 +164,6 @@ def visual_temperature_step(value: Any) -> ConfigType: # Actions -ControlAction = climate_ns.class_("ControlAction", automation.Action) StateTrigger = climate_ns.class_( "StateTrigger", automation.Trigger.template(Climate.operator("ref")) ) @@ -500,78 +494,41 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +def _literal_with_length(config: ConfigType, value: str) -> str: + # The (const char *, size_t) overload compares bytes in place, so it needs a plain + # literal rather than the default PROGMEM rendering on ESP8266, and skips a strlen. + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + +automation.register_apply_action( "climate.control", - ControlAction, CLIMATE_CONTROL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_mode", ClimateMode), + automation.ApplyField(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_ + ), + automation.ApplyField( + CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_ + ), + automation.ApplyField(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + automation.ApplyField(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + automation.ApplyField( + CONF_CUSTOM_FAN_MODE, + "set_fan_mode", + cg.std_string, + const_fn=_literal_with_length, + ), + automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), + automation.ApplyField( + CONF_CUSTOM_PRESET, + "set_preset", + cg.std_string, + const_fn=_literal_with_length, + ), + automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + call="make_call", ) -async def climate_control_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - # For custom_fan_mode/custom_preset the static-string path emits the - # (const char *, size_t) overload of set_fan_mode/set_preset to avoid - # constructing a std::string and calling runtime strlen. - FIELDS = ( - (CONF_MODE, "set_mode", ClimateMode), - (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), - (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), - (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), - (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), - (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), - (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), - (CONF_PRESET, "set_preset", ClimatePreset), - (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), - (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches ControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if (value := config.get(conf_key)) is None: - continue - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - elif type_ is cg.std_string: - # Static custom strings: emit a flash literal and pass the - # UTF-8 byte length to skip the runtime strlen inside - # set_fan_mode/set_preset. - literal = cg.safe_exp(value) - body_lines.append( - f"call.{setter}({literal}, {len(value.encode('utf-8'))});" - ) - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - apply_args = [ - (ClimateCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index a8d6d778ae4..d6aba02f920 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,34 +5,6 @@ namespace esphome::climate { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `target_temperature: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction final : public Action { - public: - using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); - ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->climate_->make_call(); - this->apply_(call, x...); - call.perform(); - } - - protected: - Climate *climate_; - ApplyFn apply_; -}; - class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { diff --git a/tests/components/climate/common.yaml b/tests/components/climate/common.yaml index 49386a16d59..3e305d921a4 100644 --- a/tests/components/climate/common.yaml +++ b/tests/components/climate/common.yaml @@ -56,8 +56,8 @@ climate: default_target_temperature_high: 22°C button: - # Exercise the climate.control: action so ControlAction templates get - # build coverage. Various field combinations are tested. + # Exercise the climate.control: action so the register_apply_action codegen + # gets build coverage. Various field combinations are tested. - platform: template name: "Climate Control Mode" on_press: @@ -84,6 +84,22 @@ button: - climate.control: id: climate_test_thermostat mode: "OFF" + # Custom strings take the (const char *, size_t) overload; the byte length of + # the non-ASCII value is computed at codegen time. + - platform: template + name: "Climate Control Custom Strings" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: "Über" + custom_preset: Default + - platform: template + name: "Climate Control Custom Lambdas" + on_press: + - climate.control: + id: climate_test_thermostat + custom_fan_mode: !lambda return "quiet"; + custom_preset: !lambda return "Default"; # Exercise climate.control inside a trigger with non-empty Ts (number on_value # passes float). From b7b1507dafc8f403c6a377409ab0d8505e71bbc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:28:03 +0100 Subject: [PATCH 399/759] [update] Use register_apply_action for update.perform (#19505) --- esphome/components/update/__init__.py | 20 ++------------------ esphome/components/update/automation.h | 7 ------- tests/components/update/common.yaml | 2 ++ 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 5ebe58881d8..818358c48a1 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -32,9 +32,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -PerformAction = update_ns.class_( - "PerformAction", automation.Action, cg.Parented.template(UpdateEntity) -) CheckAction = update_ns.class_( "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) ) @@ -133,9 +130,8 @@ async def to_code(config: ConfigType) -> None: cg.add_global(update_ns.using) -@automation.register_action( +automation.register_apply_action( "update.perform", - PerformAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), @@ -144,20 +140,8 @@ async def to_code(config: ConfigType) -> None: ): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_FORCE_UPDATE, "perform", cg.bool_), ) -async def update_perform_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - force = await cg.templatable(config[CONF_FORCE_UPDATE], args, cg.bool_) - cg.add(var.set_force(force)) - return var @automation.register_action( diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 8ba7b71a9ca..3d303fdb639 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,13 +6,6 @@ namespace esphome::update { -template class PerformAction final : public Action, public Parented { - TEMPLATABLE_VALUE(bool, force) - - public: - void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } -}; - template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } diff --git a/tests/components/update/common.yaml b/tests/components/update/common.yaml index 40042945c8b..bcfdbd8c0bb 100644 --- a/tests/components/update/common.yaml +++ b/tests/components/update/common.yaml @@ -13,6 +13,8 @@ esphome: - update.check: - update.perform: force_update: true + - update.perform: + force_update: !lambda return false; wifi: ssid: MySSID From 438307f548a63fb7b6184c89acb93e8ab09ab846 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:28:36 +0100 Subject: [PATCH 400/759] [light] Use register_apply_action for the light control actions (#19476) --- esphome/components/light/automation.h | 28 ---- esphome/components/light/automation.py | 125 ++++++------------ esphome/components/light/types.py | 2 - .../components/http_request/http_request.yaml | 2 +- tests/components/light/common.yaml | 10 ++ .../integration/test_light_control_action.py | 4 +- 6 files changed, 55 insertions(+), 116 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index ced15dfc603..5ce5f1ee601 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -31,34 +31,6 @@ template class ToggleAction final : pu transition_length_{}; }; -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `brightness: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction final : public Action { - public: - using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); - LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->parent_->make_call(); - this->apply_(this->parent_, call, x...); - call.perform(); - } - - protected: - LightState *parent_; - ApplyFn apply_; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 7eaba9b117a..d84df33a23d 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -27,7 +27,7 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -37,8 +37,6 @@ from .types import ( AddressableSet, ColorMode, DimRelativeAction, - LightCall, - LightControlAction, LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, @@ -147,7 +145,7 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -def _resolve_effect_index(config: ConfigType) -> int: +def _resolve_effect_index(config: ConfigType, original_name: str) -> int: """Resolve a static effect name to its 1-based index at codegen time. Effect index 0 means "None" (no effect). Effects are 1-indexed matching @@ -155,7 +153,6 @@ def _resolve_effect_index(config: ConfigType) -> int: """ from . import available_effects_str, find_effect_index - original_name = config[CONF_EFFECT] if original_name.lower() == "none": return 0 light_id = config[CONF_ID] @@ -173,87 +170,49 @@ def _resolve_effect_index(config: ConfigType) -> int: ) -@automation.register_action( - "light.turn_off", LightControlAction, LIGHT_TURN_OFF_ACTION_SCHEMA, synchronous=True +def _effect_index(config: ConfigType, value: str) -> str: + # Resolved at codegen time; the cast picks set_effect(uint32_t) over the optional overload. + return f"static_cast({_resolve_effect_index(config, value)})" + + +_LIGHT_CONTROL_FIELDS = ( + automation.ApplyField(CONF_COLOR_MODE, "set_color_mode", ColorMode), + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + automation.ApplyField(CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), + automation.ApplyField(CONF_RED, "set_red", cg.float_), + automation.ApplyField(CONF_GREEN, "set_green", cg.float_), + automation.ApplyField(CONF_BLUE, "set_blue", cg.float_), + automation.ApplyField(CONF_WHITE, "set_white", cg.float_), + automation.ApplyField(CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), + automation.ApplyField(CONF_COLD_WHITE, "set_cold_white", cg.float_), + automation.ApplyField(CONF_WARM_WHITE, "set_warm_white", cg.float_), + automation.ApplyField( + CONF_EFFECT, "set_effect", cg.std_string, const_fn=_effect_index + ), ) -@automation.register_action( - "light.turn_on", LightControlAction, LIGHT_TURN_ON_ACTION_SCHEMA, synchronous=True + +automation.register_apply_action( + "light.turn_off", + LIGHT_TURN_OFF_ACTION_SCHEMA, + automation.ApplyField(CONF_STATE, "set_state", cg.bool_), + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="make_call", ) -@automation.register_action( - "light.control", LightControlAction, LIGHT_CONTROL_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "light.turn_on", + LIGHT_TURN_ON_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", +) +automation.register_apply_action( + "light.control", + LIGHT_CONTROL_ACTION_SCHEMA, + *_LIGHT_CONTROL_FIELDS, + call="make_call", ) -async def light_control_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - - # All configured fields are folded into a single stateless lambda whose - # constants live in flash; the action stores only a function pointer. - FIELDS = ( - (CONF_COLOR_MODE, "set_color_mode", ColorMode), - (CONF_STATE, "set_state", cg.bool_), - (CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), - (CONF_FLASH_LENGTH, "set_flash_length", cg.uint32), - (CONF_BRIGHTNESS, "set_brightness", cg.float_), - (CONF_COLOR_BRIGHTNESS, "set_color_brightness", cg.float_), - (CONF_RED, "set_red", cg.float_), - (CONF_GREEN, "set_green", cg.float_), - (CONF_BLUE, "set_blue", cg.float_), - (CONF_WHITE, "set_white", cg.float_), - (CONF_COLOR_TEMPERATURE, "set_color_temperature", cg.float_), - (CONF_COLD_WHITE, "set_cold_white", cg.float_), - (CONF_WARM_WHITE, "set_warm_white", cg.float_), - ) - - # Normalize trigger args to `const std::remove_cvref_t &` so the - # apply lambda and any inner field lambdas (generated below via - # `process_lambda`) share one parameter spelling that's well-formed for - # any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn. - normalized_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n) - for t, n in args - ] - - fwd_args = ", ".join(name for _, name in args) - body_lines: list[str] = [] - - for conf_key, setter, type_ in FIELDS: - if conf_key not in config: - continue - value = config[conf_key] - if isinstance(value, Lambda): - inner = await cg.process_lambda(value, normalized_args, return_type=type_) - body_lines.append(f"call.{setter}(({inner})({fwd_args}));") - else: - body_lines.append(f"call.{setter}({cg.safe_exp(value)});") - - if CONF_EFFECT in config: - if isinstance(config[CONF_EFFECT], Lambda): - inner_lambda = await cg.process_lambda( - config[CONF_EFFECT], normalized_args, return_type=cg.std_string - ) - body_lines.append( - f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n" - f"call.set_effect(parent->get_effect_index(" - f"__effect_s.c_str(), __effect_s.size())); }}" - ) - else: - # Cast disambiguates between set_effect(uint32_t) and - # set_effect(optional) when the literal is an int. - body_lines.append( - f"call.set_effect(static_cast({_resolve_effect_index(config)}));" - ) - - apply_args = [ - (LightState.operator("ptr"), "parent"), - (LightCall.operator("ref"), "call"), - *normalized_args, - ] - apply_lambda = LambdaExpression( - ["\n".join(body_lines)], - apply_args, - capture="", - return_type=cg.void, - ) - return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 1778aa8410c..4d2de314f64 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -14,7 +14,6 @@ Color = cg.esphome_ns.class_("Color") LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") -LightCall = light_ns.class_("LightCall") # Addressable strips ChannelColors = light_ns.struct("ChannelColors") @@ -42,7 +41,6 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) -LightControlAction = light_ns.class_("LightControlAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 4b3c2ca36bc..172e0c235ad 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -50,7 +50,7 @@ esphome: format: "After delay, body still: %s" args: - body.c_str() - # Regression test for esphome/esphome#16224: a LightControlAction + # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen # bug where the apply lambda's parameter list did not match the diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 71c00e5f103..5ba245e7c33 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -77,6 +77,16 @@ esphome: - light.control: id: test_monochromatic_light state: on + # flash_length and a turn_off with its own transition + - light.turn_on: + id: test_rgb_light + flash_length: 750ms + - light.turn_on: + id: test_rgb_light + flash_length: !lambda return 250; + - light.turn_off: + id: test_rgb_light + transition_length: 300ms # Test static effect name resolution at codegen time - light.turn_on: id: test_monochromatic_light diff --git a/tests/integration/test_light_control_action.py b/tests/integration/test_light_control_action.py index 9a5c16a04d4..bb690645bd4 100644 --- a/tests/integration/test_light_control_action.py +++ b/tests/integration/test_light_control_action.py @@ -1,4 +1,4 @@ -"""Integration test for LightControlAction. +"""Integration test for the light control apply actions. Tests that light.turn_on, light.turn_off, and light.control automation actions work correctly with the compact per-field union storage. Exercises both constant @@ -19,7 +19,7 @@ async def test_light_control_action( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test LightControlAction with constants and lambdas.""" + """Test light.turn_on, light.turn_off and light.control with constants and lambdas.""" async with run_compiled(yaml_config), api_client_connected() as client: state_futures: dict[int, asyncio.Future[Any]] = {} From e3951f2ab43233056f1de81ab33d04b1d909181a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:30:19 +0100 Subject: [PATCH 401/759] [esp8266_pwm] Use register_apply_action for the set_frequency action (#19516) --- esphome/components/esp8266_pwm/esp8266_pwm.h | 14 -------------- esphome/components/esp8266_pwm/output.py | 19 ++----------------- tests/components/esp8266_pwm/common.yaml | 10 ++++++++++ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 87b76a392a6..7b36c410a25 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" namespace esphome::esp8266_pwm { @@ -37,19 +36,6 @@ class ESP8266PWM final : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - ESP8266PWM *parent_; -}; - } // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index be6e63b154d..62ec1fe003b 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,8 +4,6 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] @@ -19,7 +17,6 @@ def valid_pwm_pin(value: ConfigType) -> ConfigType: esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm") ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component) -SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) # Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen @@ -57,25 +54,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency(frequency)) -@automation.register_action( +automation.register_apply_action( "output.esp8266_pwm.set_frequency", - SetFrequencyAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(ESP8266PWM), cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), - synchronous=True, + automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) - cg.add(var.set_frequency(template_)) - return var diff --git a/tests/components/esp8266_pwm/common.yaml b/tests/components/esp8266_pwm/common.yaml index 52b290f91bc..7144e89e367 100644 --- a/tests/components/esp8266_pwm/common.yaml +++ b/tests/components/esp8266_pwm/common.yaml @@ -1,3 +1,13 @@ +esphome: + on_boot: + then: + - output.esp8266_pwm.set_frequency: + id: out + frequency: 100Hz + - output.esp8266_pwm.set_frequency: + id: out2 + frequency: !lambda return 200.0f; + output: - platform: esp8266_pwm id: out From f4300d9ceebdb012141fd4fcb612c462311591af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Sep 2026 23:45:54 +0100 Subject: [PATCH 402/759] [esp8266] Add the native ninja emission (#18560) --- esphome/build_gen/arduino8266.py | 502 +++++++++++++++- esphome/components/esp8266/__init__.py | 6 +- esphome/components/esp8266/const.py | 4 +- .../unit_tests/build_gen/test_arduino8266.py | 553 +++++++++++++++++- 4 files changed, 1032 insertions(+), 33 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 7d842308f65..b6ac88f1234 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1,11 +1,11 @@ -"""Build specification for the native ESP8266 Arduino toolchain. +"""Native ninja build generator for the ESP8266 Arduino core. Transliterates the PlatformIO build spec for the Arduino ESP8266 framework (``framework-arduinoespressif8266/tools/platformio-build.py`` plus -``platform-espressif8266/builder/main.py``): the flag sets, defines, and -linker-script generation deliberately match what PlatformIO produces so the -binaries stay near-identical between the two toolchains. The ninja emission -(``write_project``) builds on these pieces. +``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under +``.pioenvs//``. The flag sets, defines, link line, linker-script +generation, and ``elf2bin`` invocation deliberately match what PlatformIO +produces so the binaries stay near-identical between the two toolchains. The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK version, MMU layout, exceptions, waveform phase) keep working: they are read @@ -14,6 +14,7 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations +from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -22,20 +23,51 @@ from pathlib import Path import re import shlex import subprocess +import sys from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool -from esphome.build_helpers.ninja import shell_token as _shell_token +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import ( + BOARDS, + ESP8266_BOARD_BUILD, + board_ld_script, +) +from esphome.components.esp8266.const import ( + BUILD_FLASH_MODES, + KEY_BOARD, + KEY_ESP8266, + KEY_FLASH_SIZE, + KEY_SCANF_FLOAT, +) from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import ( + get_project_cxx_compile_flags, + strip_win_long_path_prefix, +) from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import lex_build_flags +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths _LOGGER = logging.getLogger(__name__) +# Always excluded from the core build: ESPHome uses its own native OTA +# backend, so the Arduino Updater (and its 228-byte global) never links. +_CORE_EXCLUDE_ALWAYS = {"Updater.cpp"} +# Excluded when no component called require_waveform(); waveform_stubs.cpp +# supplies the stopWaveform()/_stopPWM() stubs digitalWrite needs. +_CORE_EXCLUDE_WAVEFORM = { + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", +} + # Values that land unquoted on generated command lines are shape-checked # against these before use. re.ASCII: a Unicode digit or word character # (Arabic-Indic numerals) would pass \d/\w and defeat the named error @@ -77,6 +109,11 @@ def _apply_surgery(fn, *args: object) -> str: raise EsphomeError(str(err)) from err +# Every supported board's f_flash is 40 MHz; re-check on a platform bump +# board_flash_mode's closed set, shared with cv.one_of's validation +_FLASH_MODES = frozenset(BUILD_FLASH_MODES) +_FLASH_FREQ_MHZ = 40 + # From platformio-build.py. Knob suffix -> SDK define; the first entry is # the default (dicts preserve insertion order). With multiple SDK knobs set # (a pathological config) ties break by table order, since upstream's @@ -408,6 +445,52 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: ) +_INCOMPLETE_INSTALL = "Arduino toolchain install is incomplete" + + +def _filter_link_flags(unflags: set[str]) -> list[str]: + """_LINKFLAGS minus ``unflags``, pair-aware: unflagging a symbol also + drops the ``-u`` that carried it, so no dangling operand-less flag + reaches ld as the next token's consumer.""" + out: list[str] = [] + it = iter(_LINKFLAGS) + for tok in it: + if tok == "-u": + symbol = next(it) + if symbol not in unflags: + out += [tok, symbol] + elif tok not in unflags: + out.append(tok) + return out + + +def _active_flash_ld_name(flash_ld_name: str) -> str: + """The flash linker-script filename the link uses (testing mode renames + the surgically patched copy).""" + return ( + f"{_TESTING_LD_PREFIX}{flash_ld_name}" if CORE.testing_mode else flash_ld_name + ) + + +def _flash_ld_name(board: str) -> str: + """The flash linker script: the board's, or a routed user override. + + Published configs override board_build.ldscript to reserve a + filesystem region or correct a board's assumed flash size; a bare + name is required because the script resolves via the -L search path. + """ + override = _pio_option("board_build.ldscript", "") + if not override: + # The same shared rule the PlatformIO path pins (layout + # preservation, see boards.board_ld_script) + return board_ld_script(BOARDS[board]) + if Path(override).name != override: + raise EsphomeError( + f"board_build.ldscript must be a bare script name, got {override!r}" + ) + return override + + def _pio_option(key: str, default: str) -> str: """A platformio_options value the native build honors (str-normalized). @@ -551,11 +634,23 @@ _PLAIN_LINKER_FLAGS = ( "-nostdlib", "-rdynamic", ) +# The subset whose next token is an operand; unflagging the bare flag +# would strand the operand. Operand-less members of the list above filter +# whole-token from both the compile and link lines, as PlatformIO allows. +_PLAIN_LINKER_OPERAND_FLAGS = ("-u", "-e") _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") # Driver options, not ld options: -Wl, has no equivalent for these _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") +def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + def _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -771,3 +866,396 @@ def _generate_testing_flash_ld( write_file_if_changed( ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld ) + + +def _ninja_compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths.""" + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + lines.append( + f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" + ) + if flags: + lines.append(f" flags = {flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping. + objects.append(escaped_obj) + return objects + + +def _common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +class _FlagSets(NamedTuple): + cflags: list[str] + cxxflags: list[str] + asflags: list[str] + + +def _check_install(framework: Path, src_dir: Path, include_dirs: list[Path]) -> None: + """Fail by naming the missing path before any tool runs.""" + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + # A missing install directory would otherwise surface as a wall of + # include errors; failing here names the path instead + for required in include_dirs[1:]: + if not required.is_dir(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required}; {_CLEAN_HINT}" + ) + # The elf2bin edge runs after the full compile and link; a + # half-extracted package must fail here, not an hour of wall-clock later + for required_file in ( + framework / "tools" / "elf2bin.py", + framework / "bootloaders" / "eboot" / "eboot.elf", + ): + if not required_file.is_file(): + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: missing {required_file}; {_CLEAN_HINT}" + ) + + +def _resolve_flag_sets( + config: _BuildConfig, + defines: list[str], + includes: list[str], + project_compile_flags: list[str], + build_tokens: list[str], + unflags: set[str], +) -> _FlagSets: + """The c/cxx/as flag sets with ``build_unflags`` applied.""" + common = _CCFLAGS + defines + includes + project_compile_flags + cflags = _CFLAGS + common + cpp_standard = CORE.cpp_standard or "gnu++17" + cxxflags = ( + ["-fno-rtti", f"-std={cpp_standard}"] + + ["-fexceptions" if config.exceptions else "-fno-exceptions"] + + common + + [_shell_token(f) for f in get_project_cxx_compile_flags()] + ) + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly; match + # it (tokens arrive shell-quoted, hence the lstrip) + asflags = ( + _ASFLAGS + + defines + + includes + + [f for f in project_compile_flags if f.lstrip("\"'").startswith(("-D", "-I"))] + ) + # build_unflags applies to the framework flag sets too, as under + # PlatformIO; matching is whole-token, so an unflag that hits nothing + # anywhere must be visible + flag_universe = set(build_tokens) + for flags in (cflags, cxxflags, asflags, _LINKFLAGS): + flag_universe.update(flags) + if unmatched := sorted(unflags - flag_universe): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", ", ".join(unmatched) + ) + # _LINKFLAGS stores -u and its operand as two tokens; unflagging the + # bare -u would strip all seven and leave the operands as ld "input + # files" with an error pointing nowhere near build_unflags + if plain := sorted( + u + for u in unflags + if u in _PLAIN_LINKER_OPERAND_FLAGS or u.startswith(_PLAIN_LINKER_PREFIXES) + ): + raise EsphomeError( + f"build_unflags cannot remove plain linker flag(s) " + f"{', '.join(plain)}; unflag the full -Wl, form or the symbol" + ) + return _FlagSets( + *( + [f for f in flags if f not in unflags] + for flags in (cflags, cxxflags, asflags) + ) + ) + + +def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled) + so one build never pays the runnability probe per consumer. Returns + True when ``build.ninja`` changed, so the caller can skip work derived + purely from it (the compile database) on unchanged builds. + """ + from esphome.arduino.library import resolve_libraries + + framework = paths.framework + toolchain_bin = paths.toolchain / "bin" + build_dir = CORE.relative_pioenvs_path(CORE.name) + mkdir_p(build_dir) + + unflags = _unflag_tokens() + # Lexed once so a malformed entry warns once, not per consumer + build_tokens = _lexed_build_flags() + flag_defines = _flag_defines(unflags, build_tokens) + config = _resolve_build_config(flag_defines) + esp8266_data = CORE.data[KEY_ESP8266] + board = esp8266_data[KEY_BOARD] + # Config validation already gates boards; + # kept as defense-in-depth for direct calls, since CONF_BOARD itself is + # a free-form string + if board not in ESP8266_BOARD_BUILD: + raise EsphomeError(f"Board '{board}' is not supported by the native toolchain") + board_build = ESP8266_BOARD_BUILD[board] + # From the same producer the PlatformIO path reads (one source) + flash_mode = _pio_option("board_build.flash_mode", "dout") + if flash_mode not in _FLASH_MODES: + # Lands unquoted in the elf2bin command and a -D body; validation + # (cv.one_of on board_flash_mode) already gates it, defense-in-depth + raise EsphomeError(f"Invalid flash mode {flash_mode!r}") + flash_ld_name = _flash_ld_name(board) + + sdk = framework / "tools" / "sdk" + core_dir = framework / "cores" / "esp8266" + variant_dir = framework / "variants" / board_build["variant"] + src_dir = CORE.relative_src_path() + + include_dirs = [ + src_dir, + sdk / "include", + core_dir, + paths.toolchain / "include", + sdk / "lwip2" / "include", + variant_dir, + ] + # Completeness checks run before generate_ld_scripts spawns gcc so a + # half-extracted install names the missing path, not a gcc error + _check_install(framework, src_dir, include_dirs) + + generate_ld_scripts(paths, config, flash_ld_name) + + libraries = resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + for lib in libraries: + include_dirs += lib.include_dirs + + ( + project_compile_flags, + project_link_flags, + project_lib_dirs, + project_libs, + ) = _project_flags(unflags, build_tokens) + flag_sets = _resolve_flag_sets( + config, + _defines_flags(config, flash_mode, board, board_build["defines"]), + [f"-I{_q(d)}" for d in include_dirs], + project_compile_flags, + build_tokens, + unflags, + ) + link_flags = _filter_link_flags(unflags) + if esp8266_data[KEY_SCANF_FLOAT]: + link_flags += ["-u", "_scanf_float"] + link_flags += project_link_flags + link_flags += [_shell_token(flag) for lib in libraries for flag in lib.link_flags] + flash_ld = _active_flash_ld_name(flash_ld_name) + # A user-overridden script name re-quotes like every other user token + link_flags += ["-T", _shell_token(flash_ld)] + + lib_dirs = [Path("ld"), sdk / "lib", sdk / "ld", sdk / "lib" / config.nonosdk] + lib_dirs += project_lib_dirs + for lib in libraries: + lib_dirs += lib.link_dirs + system_libs = ( + _SYSTEM_LIBS_PRE_LWIP + + [config.lwip_lib] + + _SYSTEM_LIBS_POST_LWIP + + project_libs + + [lib_name for lib in libraries for lib_name in lib.link_libs] + + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] + ) + + build_tool = Path(__file__).parent / "build_tool.py" + + # $in/$out stay unquoted: ninja escapes its built-in path variables + # itself; only literal paths need _q(). + lines = [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", + f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(build_tool)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) + "rule c", + " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + "rule ar", + f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + "rule elf2bin", + # --flash_size deliberately stays board-derived, as under + # PlatformIO (which reads upload.maximum_size, not the ldscript). + f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + " description = BIN $out", + "rule copy", + " command = $python $buildtool copy $in $out", + " description = COPY $out", + "", + f"cflags = {' '.join(flag_sets.cflags)}", + f"cxxflags = {' '.join(flag_sets.cxxflags)}", + f"asflags = {' '.join(flag_sets.asflags)}", + f"linkflags = {' '.join(link_flags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(_shell_token(f'-l{lib}') for lib in system_libs)}", + "", + ] + + core_exclude = set(_CORE_EXCLUDE_ALWAYS) + if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: + core_exclude |= _CORE_EXCLUDE_WAVEFORM + + archives = [] + direct_objs: list[str] = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = _collect_sources(variant_dir) + if variant_sources: + objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = _ninja_compile_edges( + lines, _collect_sources(core_dir, core_exclude), core_dir, "core" + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do. + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + lib_root = _common_parent(lib.sources) + objs = _ninja_compile_edges( + lines, + lib.sources, + lib_root, + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + + # One source of truth with the PlatformIO path: esp8266/__init__ pins + # build_src_flags (the throw_stubs force-include); -include paths + # resolve against the source root + src_parts: list[str] = [] + src_it = iter( + lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") + ) + for tok in src_it: + if tok == "-include": + header = next(src_it, "") + if not header: + raise EsphomeError( + "build_src_flags has a trailing '-include' with no header" + ) + src_parts.append(f"-include {_q(src_dir / header)}") + else: + src_parts.append(_shell_token(tok)) + src_extra = " ".join(src_parts) + # One shared variable instead of repeating the flags line on every src + # edge (hundreds of edges in a real project) + lines.append(f"srcflags = {src_extra}") + src_objs = _ninja_compile_edges( + lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags" + ) + + ld_deps = [f"ld/{_COMMON_LD_NAME}"] + if CORE.testing_mode: + ld_deps.append(f"ld/{flash_ld}") + lines.append( + f"build firmware.elf: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)} {' '.join(_e(d) for d in ld_deps)}" + ) + lines.append(f" archives = {' '.join(_shell_token(a) for a in archives)}") + lines.append("build firmware.bin: elf2bin firmware.elf") + lines.append("build firmware.factory.bin: copy firmware.bin") + lines.append("build firmware.ota.bin: copy firmware.bin") + lines.append("default firmware.factory.bin firmware.ota.bin") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) + + +def get_flash_ld_path(build_dir: Path, paths: InstalledPaths) -> Path: + """The flash linker script the link actually uses (for size reporting). + + Reads the same install the ninja file linked against instead of + re-resolving the framework version. A user-shipped override living in a + custom -L dir resolves to a nonexistent path here; the size consumer + warns and skips the Flash summary then. + """ + name = _active_flash_ld_name(_flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])) + if CORE.testing_mode: + return build_dir / "ld" / name + return paths.framework / "tools" / "sdk" / "ld" / name + + +def _flash_size_str(flash_size: int) -> str: + """Flash size argument for elf2bin (e.g. ``4M``, ``512K``).""" + mb = 1024 * 1024 + return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index cef0e6ea11d..56509b9fabb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -37,6 +37,7 @@ from esphome.types import ConfigType from .boards import BOARDS, board_ld_script from .const import ( + BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, @@ -44,6 +45,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, + KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, @@ -233,7 +235,6 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( ) -BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -315,6 +316,9 @@ async def to_code(config: ConfigType) -> None: "enabling scanf float support (~8KB flash)" ) + # The native generator reads the same decision (KEY_SCANF_FLOAT) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) + extra_scripts = [ "pre:ccache.py", "pre:testing_mode.py", diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 50f103ed2db..ac2fe0aa3b4 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -16,7 +16,6 @@ KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator -KEY_FLASH_MODE = "flash_mode" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" @@ -73,3 +72,6 @@ def enable_serial1() -> None: enable_serial1() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True + + +BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout") diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 21a81804aba..4ad88347e2a 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1,38 +1,34 @@ -"""Drift tests for the native ESP8266 Arduino build spec. +"""Drift tests for the native ESP8266 Arduino build generator. -These pin the ESPHome side of the transliteration (the knob-define -precedence, the define/flag sets, and the linker-script generation) against -literals audited from the PlatformIO builder -(framework-arduinoespressif8266/tools/platformio-build.py and -platform-espressif8266/builder/main.py). They catch an accidental edit on -this side; an upstream change in a new framework release is caught by the -A/B byte-identical build check on a version bump, not by these tests. +Pin the transliterated flag/define/link sets against literals audited from +the PlatformIO builder. Upstream drift is caught by the A/B build check on +version bumps, not here. """ from __future__ import annotations from collections.abc import Generator +import logging import os from pathlib import Path +import shutil from unittest.mock import MagicMock, patch import pytest +from esphome.arduino.library import ArduinoLibrary from esphome.arduino8266.framework import InstalledPaths, toolchain_tool from esphome.build_gen import arduino8266 from esphome.build_gen.arduino8266 import ( _defines_flags, _flag_defines, + _flash_size_str, _resolve_build_config, + get_flash_ld_path, ) -from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import ( - KEY_BOARD, - KEY_ESP8266, - KEY_FLASH_MODE, - KEY_SCANF_FLOAT, -) +from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -47,9 +43,13 @@ def _setup_core(tmp_path: Path) -> Generator[None]: CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} CORE.data[KEY_ESP8266] = { KEY_BOARD: "nodemcuv2", - KEY_FLASH_MODE: "dout", KEY_SCANF_FLOAT: False, } + # The producers esp8266/__init__ pins unconditionally + CORE.platformio_options = { + "board_build.flash_mode": "dout", + "build_src_flags": "-include esphome/components/esp8266/throw_stubs.h", + } yield # CORE.reset() (the suite-wide autouse fixture) does not clear this flag CORE.testing_mode = False @@ -59,6 +59,11 @@ def _set_flags(*flags: str) -> None: CORE.build_flags = set(flags) +def _shq(tok: str) -> str: + """The platform's shell_token quote wrapper (argv rule on Windows).""" + return f'"{tok}"' if os.name == "nt" else f"'{tok}'" + + def _resolve(*flags: str) -> arduino8266._BuildConfig: """Set the build flags and resolve the knob config in one step.""" _set_flags(*flags) @@ -206,12 +211,173 @@ def _make_framework(tmp_path: Path) -> InstalledPaths: for sub in ("include", "ld", "lwip2/include", "lib"): (framework / "tools" / "sdk" / sub).mkdir(parents=True) (framework / "libraries").mkdir() + (framework / "tools" / "elf2bin.py").write_text("") + eboot = framework / "bootloaders" / "eboot" + eboot.mkdir(parents=True) + (eboot / "eboot.elf").write_text("") toolchain = tmp_path / "toolchain" (toolchain / "bin").mkdir(parents=True) (toolchain / "include").mkdir() return InstalledPaths(framework=framework, toolchain=toolchain, ninja=Path("ninja")) +def _write_ninja( + paths: InstalledPaths, + libraries: list | None = None, + ccache: str | None = None, +) -> str: + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + (src / "esphome" / "vendor.c").write_text("") + + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch( + "esphome.arduino.library.resolve_libraries", + return_value=libraries or [], + ), + ): + arduino8266.write_project(paths, ccache) + return (CORE.relative_pioenvs_path(CORE.name) / "build.ninja").read_text() + + +def test_write_project_rejects_bad_flash_mode(tmp_path: Path) -> None: + """A flash mode outside the closed set fails by name before landing + unquoted in the elf2bin command.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dout; rm -rf /" + with pytest.raises(EsphomeError, match="Invalid flash mode"): + _write_ninja(paths) + + +def test_write_project_flash_mode_reaches_define_and_elf2bin( + tmp_path: Path, +) -> None: + """A non-default board_flash_mode lands in both the compile define and + the elf2bin image header, never silently falling back to dout.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["board_build.flash_mode"] = "dio" + content = _write_ninja(paths) + assert "-DFLASHMODE_DIO" in content + assert "--flash_mode dio" in content + assert "FLASHMODE_DOUT" not in content + + +def test_write_project_trailing_include_raises(tmp_path: Path) -> None: + """A dangling -include must fail by name, not become -include .""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = "-include" + with pytest.raises(EsphomeError, match="trailing '-include'"): + _write_ninja(paths) + + +def test_write_project_passes_other_src_flags_through(tmp_path: Path) -> None: + """Non-include build_src_flags tokens are requoted onto the src edges.""" + paths = _make_framework(tmp_path) + CORE.platformio_options["build_src_flags"] = ( + "-include esphome/components/esp8266/throw_stubs.h -DSRC_ONLY=1" + ) + content = _write_ninja(paths) + assert "throw_stubs.h" in content + assert "-DSRC_ONLY=1" in content + + +def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + "-DUSE_ESP8266_WAVEFORM_STUBS", + "-Wl,--wrap=millis", + "-Wl,--wrap=printf", + "-Wno-nonnull-compare", + "-L/opt/blobs", + "-luser_blob", + "-L /spc/blobs -l spaced_blob", + ) + content = _write_ninja(paths) + + # Base link flags from the PlatformIO builder + for flag in ( + "-Wl,--no-check-sections", + "-Wl,-static", + "-Wl,--gc-sections", + "-Wl,-wrap,system_restart_local", + "-Wl,-wrap,spi_flash_read", + "-u app_entry", + "-u _printf_float", + "-u _DebugExceptionVector", + "-u _DoubleExceptionVector", + "-u _KernelExceptionVector", + "-u _NMIExceptionVector", + "-u _UserExceptionVector", + ): + assert flag in content + # ESPHome's link flags and the board linker script + assert "-Wl,--wrap=millis" in content + assert "-Wl,--wrap=printf" in content + assert "-T eagle.flash.4m.ld" in content + # scanf float disabled: the forced-link flag must not appear + assert "_scanf_float" not in content + # $in/$out must stay unquoted; ninja escapes its own path variables + assert "-c $in -o $out" in content + assert "--app $in --flash_mode" in content + assert '"$in"' not in content + assert '"$out"' not in content + # -L/-l from esphome build_flags reach the link line, not the compiles; + # spaced forms ("-L /path") are shell-lexed the way PlatformIO does. + # str(Path(...)) so the separator matches the host platform. + opt_blobs = str(Path("/opt/blobs")) + spc_blobs = str(Path("/spc/blobs")) + assert f"-L{_shq(opt_blobs)}" in content + assert "-luser_blob" in content + assert f"-L{_shq(spc_blobs)}" in content + assert "-lspaced_blob" in content + for line in content.splitlines(): + if line.split(" = ")[0] in ("cflags", "cxxflags", "asflags"): + assert "user_blob" not in line + assert opt_blobs not in line + assert "spaced_blob" not in line + assert spc_blobs not in line + # System libraries with the selected lwIP variant, in the builder's order + assert ( + "-lhal -lphy -lpp -lnet80211 -llwip2-1460 -lwpa -lcrypto -lmain -lwps " + "-lbearssl -lespnow -lsmartconfig -lairkiss -lwpa2 -lspaced_blob " + "-luser_blob " + "-lstdc++ -lm -lc -lgcc" in content + ) + # Core exclusions: native OTA backend and waveform stubs + assert "Updater.cpp" not in content + assert "core_esp8266_waveform_pwm.cpp" not in content + assert "core_esp8266_waveform_phase.cpp" not in content + assert "core_esp8266_main.cpp.o" in content + # Assembly and C sources compile through their own rules + assert "cont.S.o: aspp" in content + assert "abi.c.o: c" in content + # throw_stubs is force-included for ESPHome sources only, via one shared + # srcflags variable rather than a copy of the flags line per edge + src_lines = [line for line in content.splitlines() if "obj/src/" in line] + assert any("main.cpp.o: cxx" in line for line in src_lines) + assert content.count("throw_stubs.h") == 1 + assert "srcflags = -include" in content + flags_lines = [ + line for line in content.splitlines() if line.startswith(" flags = ") + ] + assert flags_lines + assert all(line == " flags = $srcflags" for line in flags_lines) + + +def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "-u _scanf_float" in content + # Waveform not stubbed out: both implementations stay in the archive + assert "core_esp8266_waveform_pwm.cpp.o" in content + assert "core_esp8266_waveform_phase.cpp.o" in content + + @pytest.mark.parametrize( ("knob", "lib", "mss", "features", "ipv6"), [ @@ -360,8 +526,8 @@ def test_generate_ld_scripts(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) mock_run.assert_not_called() - # A surgery edit invalidates the stamp via the fingerprint (a stale - # linker script would otherwise persist until an esphome clean) + # An edit to the surgery constants invalidates the stamp (a stale linker + # script would otherwise persist until an esphome clean) with ( patch.object( arduino8266.build_surgery, "surgery_fingerprint", return_value="changed" @@ -401,8 +567,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: paths = _make_framework(tmp_path) (paths.framework / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( - # Real flash ld scripts carry no iram1_0_seg (that lives in the - # generated common ld); the surgery rejects one it was not asked about "MEMORY\n{\n" " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" @@ -416,6 +580,96 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: assert "len = 0x2000000" in patched +def test_write_project_libraries_and_variant( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + + paths = _make_framework(tmp_path) + variant_src = paths.framework / "variants" / "nodemcu" / "variant.cpp" + variant_src.write_text("") + + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + (lib_dir / "impl.cc").write_text("") + headers_only = ArduinoLibrary(name="HeadersOnly", include_dirs=[lib_dir]) + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "impl.cc", lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + link_dirs=[lib_dir / "blobs"], + link_libs=["algobsec"], + link_flags=["-Wl,--wrap=malloc"], + ) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") + + with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"): + content = _write_ninja( + paths, libraries=[library, headers_only], ccache="/cc/ccache" + ) + + assert "build libFrameworkArduinoVariant.a: ar" in content + assert "build libMyLib.a: ar" in content + # A headers-only library contributes includes but no archive, with a + # debug log distinguishing it from a resolution failure + assert "libHeadersOnly.a" not in content + assert "Library HeadersOnly has no source files" in caplog.text + assert " flags = -DMYLIB=1" in content + assert "-lalgobsec" in content + # Library link flags reach the firmware link line; .cc compiles as C++ + assert "-Wl,--wrap=malloc" in content + assert "impl.cc.o: cxx" in content + assert f"-L{_shq(str(lib_dir / 'blobs'))}" in content + # Exceptions knob: -fexceptions and the exception-enabled stdc++ + assert "-fexceptions" in content + assert "-lstdc++-exc" in content + assert f"ccache = {_shq('/cc/ccache')}" in content + + +def test_get_flash_ld_path(tmp_path: Path) -> None: + + paths = InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=Path("ninja"), + ) + CORE.testing_mode = True + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "ld" / "testing_eagle.flash.4m.ld" + ) + + CORE.testing_mode = False + # Reads the same install the ninja file linked against; no re-resolve + assert get_flash_ld_path(tmp_path, paths) == ( + tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld" + ) + + +def test_flash_size_str() -> None: + assert _flash_size_str(4 * 1024 * 1024) == "4M" + assert _flash_size_str(512 * 1024) == "512K" + + +def test_write_project_testing_mode(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + CORE.testing_mode = True + _set_flags() + content = _write_ninja(paths) + assert "-T testing_eagle.flash.4m.ld" in content + assert "ld/testing_eagle.flash.4m.ld" in content + + +def test_write_project_missing_framework_dir_raises(tmp_path: Path) -> None: + """An incomplete framework install fails naming the missing path.""" + + paths = _make_framework(tmp_path) + shutil.rmtree(paths.framework / "tools" / "sdk" / "lwip2") + _set_flags() + with pytest.raises(EsphomeError, match="incomplete.*lwip2"): + _write_ninja(paths) + + def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( tmp_path: Path, ) -> None: @@ -440,6 +694,61 @@ def test_build_config_nonosdk_precedence() -> None: assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" +def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None: + """A lowercase .s source assembles plain (SCons AS), never through the + preprocessor rule that a .S source gets.""" + paths = _make_framework(tmp_path) + core_dir = paths.framework / "cores" / "esp8266" + (core_dir / "lowlevel.s").write_text("nop\n") + _set_flags() + content = _write_ninja(paths) + assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( + content + ) + + +def test_write_project_unflags_operandless_linker_flag(tmp_path: Path) -> None: + """build_unflags: -nostdlib filters whole-token from both lines, as + PlatformIO allows; only operand-taking flags hard-error.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-nostdlib"} + content = _write_ninja(paths) + assert "-nostdlib" not in content + + +def test_write_project_unflagged_symbol_takes_its_dash_u(tmp_path: Path) -> None: + """Unflagging a -u symbol drops the -u that carried it; a dangling -u + would consume the next token and hand ld a symbol as an input file.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"_printf_float"} + content = _write_ninja(paths) + link_line = next( + line for line in content.splitlines() if line.startswith("linkflags = ") + ) + assert "_printf_float" not in link_line + assert "-u -u" not in link_line + # The neighbors survive as intact pairs + assert "-u app_entry" in link_line + assert "-u _DebugExceptionVector" in link_line + + +def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None: + """build_unflags removes flags from the framework sets, as PlatformIO does.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-fipa-pta", "-Wl,--gc-sections"} + content = _write_ninja(paths) + for line in content.splitlines(): + key = line.split(" = ")[0] + if key in ("cflags", "cxxflags", "asflags"): + assert "-fipa-pta" not in line + if key == "linkflags": + assert "-Wl,--gc-sections" not in line + + def test_project_flags_trailing_bare_linker_flag_warns( caplog: pytest.LogCaptureFixture, ) -> None: @@ -494,10 +803,18 @@ def test_project_flags_requotes_lexed_defines() -> None: compile_flags, _link, _dirs, _libs = _split_flags() # shlex folds the quotes (as PIO's ParseFlags does); _shell_token # re-quotes the spaced token so the shell passes one argv element - expected = ( - '"-DGREETING=hello world"' if os.name == "nt" else "'-DGREETING=hello world'" - ) - assert compile_flags == [expected] + assert compile_flags == [_shq("-DGREETING=hello world")] + + +def test_write_project_empty_core_raises(tmp_path: Path) -> None: + """A framework tree with no core sources fails at generation, not link.""" + paths = _make_framework(tmp_path) + core = paths.framework / "cores" / "esp8266" + for f in core.iterdir(): + f.unlink() + _set_flags() + with pytest.raises(EsphomeError, match="no core sources"): + _write_ninja(paths) def test_flag_defines_joins_spaced_define() -> None: @@ -508,6 +825,75 @@ def test_flag_defines_joins_spaced_define() -> None: assert "" not in defines +def test_ninja_path_escaping() -> None: + """Build-statement paths and command-line paths escape differently.""" + assert arduino8266._e("a b:$c") == "a$ b$:$$c" + assert arduino8266._q("/a b/$x") == _shq("/a b/$$x") + + +def test_write_project_asm_excludes_non_define_user_flags(tmp_path: Path) -> None: + """The ASPPCOM command under PlatformIO never sees CCFLAGS, so only -D/-I user flags + reach assembly compiles.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSER_KNOB=1", "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert "-DUSER_KNOB=1" in asflags + assert "-Wno-volatile" not in asflags + cxxflags = next( + line for line in content.splitlines() if line.startswith("cxxflags") + ) + assert "-Wno-volatile" in cxxflags + + +def test_write_project_returns_changed(tmp_path: Path) -> None: + """The documented contract: True when build.ninja changed, False on an + identical regeneration (pins byte-stable output too).""" + paths = _make_framework(tmp_path) + _set_flags() + src = CORE.relative_src_path() + (src / "esphome" / "components" / "esp8266").mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + ): + assert arduino8266.write_project(paths, None) is True + assert arduino8266.write_project(paths, None) is False + + +def test_write_project_missing_elf2bin_raises(tmp_path: Path) -> None: + """A half-extracted package must fail by name at generation, not after + the full compile at the elf2bin edge.""" + paths = _make_framework(tmp_path) + (paths.framework / "tools" / "elf2bin.py").unlink() + _set_flags() + src = CORE.relative_src_path() + (src / "main.cpp").parent.mkdir(parents=True, exist_ok=True) + (src / "main.cpp").write_text("") + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + pytest.raises(EsphomeError, match="elf2bin"), + ): + arduino8266.write_project(paths, None) + + +def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None: + """A missing generated source tree is its own error, not an install one.""" + paths = _make_framework(tmp_path) + _set_flags() + with ( + patch.object(arduino8266, "generate_ld_scripts"), + patch("esphome.arduino.library.resolve_libraries", return_value=[]), + patch.object( + arduino8266.CORE, "relative_src_path", return_value=tmp_path / "nope" + ), + pytest.raises(EsphomeError, match="source directory"), + ): + arduino8266.write_project(paths, None) + + def test_build_config_custom_mmu_without_knob_raises() -> None: """Custom MMU sizes without the CUSTOM knob are refused.""" with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"): @@ -600,6 +986,49 @@ def test_generate_ld_scripts_missing_compiler_is_clean(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_asm_keeps_quoted_defines(tmp_path: Path) -> None: + """A spaced -D/-I user flag arrives shell-quoted; assembly must still + receive it.""" + paths = _make_framework(tmp_path) + _set_flags('-DGREETING="hello world"', "-Wno-volatile") + content = _write_ninja(paths) + asflags = next(line for line in content.splitlines() if line.startswith("asflags")) + assert _shq("-DGREETING=hello world") in asflags + assert "-Wno-volatile" not in asflags + + +def test_write_project_unarchived_library_links_objects(tmp_path: Path) -> None: + """A libArchive:false library's objects reach the link directly.""" + + paths = _make_framework(tmp_path) + lib_src = tmp_path / "gdb" / "src" + lib_src.mkdir(parents=True) + (lib_src / "GDBStub.cpp").write_text("") + _set_flags() + lib = ArduinoLibrary( + name="GDBStub", + sources=[lib_src / "GDBStub.cpp"], + include_dirs=[lib_src], + lib_archive=False, + ) + content = _write_ninja(paths, libraries=[lib]) + assert "libGDBStub.a" not in content + link_line = next( + line for line in content.splitlines() if line.startswith("build firmware.elf") + ) + assert "GDBStub.cpp.o" in link_line + + +def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None: + """A caller bypassing config validation gets the board named, not a + KeyError.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.data[KEY_ESP8266][KEY_BOARD] = "not_a_board" + with pytest.raises(EsphomeError, match="'not_a_board' is not supported"): + _write_ninja(paths) + + def test_unflag_tokens_join_spaced_entries() -> None: """Spaced build_unflags entries ("-D FOO") match the joined token.""" CORE.build_unflags = {"-D FOO", "-l bar"} @@ -1072,6 +1501,31 @@ def test_generate_ld_scripts_surgery_failure_is_named(tmp_path: Path) -> None: _run_generate_ld_scripts(paths) +def test_write_project_unmatched_unflag_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unflag that removes nothing is named; a matching one is silent.""" + paths = _make_framework(tmp_path) + _set_flags("-DUSE_FOO=1") + CORE.build_unflags = {"-DUSE_FOO", "-Os"} + content = _write_ninja(paths) + assert "matched no build flag: -DUSE_FOO" in caplog.text + assert "-Os" not in caplog.text.split("matched no build flag")[-1].splitlines()[0] + # The matching -Os unflag really removed the framework flag + cflags = next(line for line in content.splitlines() if line.startswith("cflags")) + assert " -Os " not in cflags + + +def test_write_project_lexes_build_flags_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A malformed build_flags entry warns once per generation.""" + paths = _make_framework(tmp_path) + _set_flags("-DFOO=1 -l") + _write_ninja(paths) + assert caplog.text.count("Ignoring trailing '-l'") == 1 + + def test_build_config_mmu_conflict_names_the_variant_knob_with_custom() -> None: """With MMU_CUSTOM also set, the actionable fix is dropping the variant knob, not setting the knob the user already set.""" @@ -1171,6 +1625,11 @@ def test_generate_ld_scripts_unreadable_header_forces_regeneration( mock_run.assert_called_once() +def test_board_tables_are_equal() -> None: + """BOARDS and ESP8266_BOARD_BUILD must stay exactly in sync.""" + assert set(BOARDS) == set(ESP8266_BOARD_BUILD) + + def test_bare_include_and_define_dropped( caplog: pytest.LogCaptureFixture, ) -> None: @@ -1208,3 +1667,49 @@ def test_defines_flags_honors_f_cpu_override() -> None: CORE.platformio_options = {"board_build.f_cpu": "160000000L"} defines = _defines_flags(config, "dout", "nodemcuv2", board_build["defines"]) assert "-DF_CPU=160000000L" in defines + + +def test_flash_ld_name_honors_ldscript_override(tmp_path: Path) -> None: + """board_build.ldscript (filesystem reservation, corrected flash size) + replaces the board default; a path is rejected since the name resolves + via the -L search path.""" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + CORE.platformio_options = {"board_build.ldscript": "eagle.flash.4m2m.ld"} + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m2m.ld" + paths = _make_framework(tmp_path) + _set_flags() + content = _write_ninja(paths) + assert "-T eagle.flash.4m2m.ld" in content + CORE.platformio_options = {"board_build.ldscript": "../evil.ld"} + with pytest.raises(EsphomeError, match="bare script name"): + arduino8266._flash_ld_name("nodemcuv2") + + +def test_unflagging_a_plain_linker_flag_raises(tmp_path: Path) -> None: + """build_unflags: -u would strip all seven -u tokens and leave the + operands as ld input files; refuse by name instead.""" + paths = _make_framework(tmp_path) + _set_flags() + CORE.build_unflags = {"-u _printf_float"} + with pytest.raises(EsphomeError, match="cannot remove plain linker"): + _write_ninja(paths) + + +def test_d1_wroom_02_keeps_its_shipped_flash_layout() -> None: + """The board joined BOARDS late; the flash-size default (2m.ld) would + move _FS_end and the preferences sector on existing devices.""" + assert arduino8266._flash_ld_name("d1_wroom_02") == "eagle.flash.2m64.ld" + assert arduino8266._flash_ld_name("nodemcuv2") == "eagle.flash.4m.ld" + + +def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None: + """A spaced override never reaches the link line: generate_ld_scripts + rejects the name first (the -T _shell_token quoting behind it is + defence-in-depth).""" + CORE.platformio_options = {"board_build.ldscript": "my script.ld"} + paths = _make_framework(tmp_path) + _set_flags() + # The completeness checks run first; give them a src dir to pass + CORE.relative_src_path().mkdir(parents=True, exist_ok=True) + with pytest.raises(EsphomeError, match="Invalid flash linker script name"): + arduino8266.write_project(paths, None) From 807556171bcc9b4abc3a1bc42f00898dee9eb01d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 00:00:59 +0100 Subject: [PATCH 403/759] [core][cover] Add register_apply_condition for conditions that only test the parent (#19540) --- AGENTS.md | 13 ++ esphome/automation.py | 160 +++++++++++++++------ esphome/components/cover/__init__.py | 27 ++-- esphome/components/cover/automation.h | 13 -- esphome/components/rtttl/__init__.py | 5 +- esphome/components/rtttl/rtttl.h | 6 - esphome/core/base_automation.h | 12 ++ tests/components/template/common-base.yaml | 12 +- tests/unit_tests/test_automation.py | 107 +++++++++++++- 9 files changed, 264 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf78daa5a37..113cfe1b2a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -470,6 +470,19 @@ file does, and it is the authority when they disagree. The most useful starting Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. + **Conditions that only test their parent need no C++ class either.** Register them with + `register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares + against config values. + ```python + automation.register_apply_condition("my_component.is_active", schema, "is_active()") + automation.register_apply_condition( + "my_component.state_is", + schema, + automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)), + ) + ``` + `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. + * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. * **Configuration Validation:** diff --git a/esphome/automation.py b/esphome/automation.py index 373f924e4db..850ac554af1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -214,6 +214,7 @@ validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) +ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) def flash_string(config: ConfigType, value: str) -> str: @@ -255,6 +256,13 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def members(self) -> list[tuple[Any, Any, Any]]: + """Each arg as ``(conf_key, type_, const_fn or None)``.""" + return [ + (arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in self.args + ] + @dataclass(frozen=True) class ApplyField: @@ -322,6 +330,58 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +async def _apply_parent(config: ConfigType) -> str: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + return f"::{await cg.get_variable(config[CONF_ID])}" + + +def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: + # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function + # pointer conversion. + return [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + + +async def _render_values( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str, + lambda_args: TemplateArgsType, + compare: bool = False, +) -> list[str]: + """Render the argument text of one statement; every key must be present. + + ``compare``: an inlined lambda expression is parenthesized so it binds as a whole + beside an operator. + """ + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda(value, lambda_args, return_type=type_) + expr = call_lambda(inner) + bare = compare and isinstance(expr, cg.RawExpression) + exprs.append(f"({expr})" if bare else str(expr)) + elif const_fn is not None: + exprs.append(const_fn(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + return exprs + + +def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> list[Any]: + return [_config_lookup(config, key) for key, _, _ in members] + + def register_apply_action( name: str, schema: cv.Schema, @@ -334,10 +394,14 @@ def register_apply_action( in, lambdas are called inline with the trigger args. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. """ + # An action stores the value, so a std::string constant stays in flash on ESP8266. statements_spec = [ ( c.target, - [(arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in c.args], + [ + (key, t, fn or (flash_string if t is cg.std_string else None)) + for key, t, fn in c.members + ], ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -351,37 +415,17 @@ def register_apply_action( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - # Global-scope qualified so a trigger arg named like the id cannot shadow it. - parent = f"::{await cg.get_variable(config[CONF_ID])}" - # Must match ApplyAction::ApplyFn exactly for the function pointer conversion. - lambda_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) - for t, arg in args - ] + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] for target, members in statements_spec: - values = [_config_lookup(config, key) for key, _, _ in members] + values = _apply_values(config, members) if members and all(value is None for value in values): continue - if any(value is None for value in values): - keys = [key for key, _, _ in members] - raise EsphomeError(f"{name}: {target!r} needs all of {keys}") - exprs: list[str] = [] - for (_, type_, const_fn), value in zip(members, values, strict=True): - if isinstance(value, Lambda): - if isinstance(type_, str): - type_ = cg.RawExpression(type_.format(parent=parent)) - inner = await cg.process_lambda( - value, lambda_args, return_type=type_ - ) - exprs.append(str(call_lambda(inner))) - elif const_fn is not None: - exprs.append(const_fn(config, value)) - elif type_ is cg.std_string: - exprs.append(flash_string(config, value)) - else: - exprs.append(str(cg.safe_exp(value))) + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args + ) statements.append(f"{receiver}{target.format(*exprs)};") if call: statements = [ @@ -397,6 +441,51 @@ def register_apply_action( register_action(name, ApplyAction, schema, synchronous=True)(builder) +def register_apply_condition( + name: str, schema: cv.Schema, check: str | ApplyCall +) -> None: + """Register a condition that is one expression on its parent, with no C++ class. + + ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an + ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares + against config values, all of which must be present. Write ``== false`` to negate. + String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + Generates one stateless function for ``ApplyCondition``. + """ + call = check if isinstance(check, ApplyCall) else ApplyCall(check) + members = call.members + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) + exprs = await _render_values( + name, + call.target, + members, + _apply_values(config, members), + config, + parent, + lambda_args, + compare=True, + ) + check_lambda = LambdaExpression( + [f"return {parent}->{call.target.format(*exprs)};"], + lambda_args, + capture="", + return_type=cg.bool_, + ) + return cg.new_Pvariable(condition_id, template_arg, check_lambda) + + register_condition(name, ApplyCondition, schema)(builder) + + def validate_potentially_and_condition(value): if isinstance(value, list): with cv.remove_prepend_path(["and"]): @@ -643,28 +732,15 @@ async def for_condition_to_code( return var -@register_condition( +register_apply_condition( "component.is_idle", - LambdaCondition, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(cg.Component), } ), + "is_idle()", ) -async def component_is_idle_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - lambda_ = await cg.process_lambda( - Lambda(f"return {comp}->is_idle();"), args, return_type=bool - ) - return new_lambda_pvariable( - condition_id, lambda_, StatelessLambdaCondition, template_arg - ) @register_action( diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index f7e029ad9e3..0f5aa9b4503 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,7 +1,7 @@ import logging from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -36,7 +36,7 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -44,7 +44,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, SafeExpType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -87,8 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) -CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( "CoverOpenedTrigger", automation.Trigger.template() ) @@ -287,19 +285,12 @@ COVER_CONDITION_SCHEMA = cv.maybe_simple_value( ) -async def cover_condition_to_code( - config: ConfigType, condition_id: ID, template_arg: MockObj, args: TemplateArgsType -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) - - -automation.register_condition( - "cover.is_open", CoverIsOpenCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) -automation.register_condition( - "cover.is_closed", CoverIsClosedCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) +automation.register_apply_condition( + "cover.is_open", COVER_CONDITION_SCHEMA, f"position == {COVER_OPEN}" +) +automation.register_apply_condition( + "cover.is_closed", COVER_CONDITION_SCHEMA, f"position == {COVER_CLOSED}" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 9af4a1eee50..2032eba0ec3 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,19 +6,6 @@ namespace esphome::cover { -template class CoverPositionCondition final : public Condition { - public: - CoverPositionCondition(Cover *cover) : cover_(cover) {} - - bool check(const Ts &...x) override { return this->cover_->position == (OPEN ? COVER_OPEN : COVER_CLOSED); } - - protected: - Cover *cover_; -}; - -template using CoverIsOpenCondition = CoverPositionCondition; -template using CoverIsClosedCondition = CoverPositionCondition; - template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4da64a8304d..4f4d17b441e 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -18,7 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -120,12 +119,12 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "rtttl.is_playing", - IsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), + "is_playing()", ) diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 0bf2fce70f5..7a32b79b177 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -116,9 +115,4 @@ class Rtttl final : public Component { #endif }; -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::rtttl diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index b0c4b649957..703c7b40e33 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -267,6 +267,18 @@ template class ApplyAction final : public Action { ApplyFn apply_; }; +/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. +template class ApplyCondition final : public Condition { + public: + using CheckFn = bool (*)(const std::remove_cvref_t &...); + explicit ApplyCondition(CheckFn check) : check_(check) {} + + bool check(const Ts &...x) override { return this->check_(x...); } + + protected: + CheckFn check_; +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 81b13de7bf0..ccef9ded052 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -551,11 +551,17 @@ text: update_interval: 1s lambda: | return std::string{"Hello!"}; + # Exercise a register_apply_condition inside a trigger with a std::string + # argument, so ApplyCondition is compiled. set_action: then: - - logger.log: - format: Template Text set to %s - args: ["x.c_str()"] + - if: + condition: + cover.is_open: template_cover_with_triggers + then: + - logger.log: + format: Template Text set to %s + args: ["x.c_str()"] alarm_control_panel: - platform: template diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c7c5ae998e9..c4265a78885 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -10,6 +10,7 @@ import pytest from esphome.automation import ( ApplyAction, ApplyCall, + ApplyCondition, ApplyField, CallbackAutomation, TriggerForwarder, @@ -19,6 +20,7 @@ from esphome.automation import ( has_non_synchronous_actions, maybe_simple_id, register_apply_action, + register_apply_condition, register_bare_action, register_bare_condition, register_parented_action, @@ -602,6 +604,20 @@ def test_shared_builders_keep_synchronous_flag( assert actions["my.parented"].synchronous is synchronous +async def _run_entry( + entry: RegistryEntry, + config: dict[str, object], + args: list[tuple[object, str]] | None, + platform: str, +) -> RegistryEntry: + """Run a registered builder with the given config, trigger args and platform.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + async def _run_apply_action( registries: tuple[Registry, Registry], fields: tuple[ApplyField | ApplyCall, ...], @@ -611,14 +627,22 @@ async def _run_apply_action( platform: str = "esp32", ) -> RegistryEntry: """Register an apply action and run its builder with the given config.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} actions, _ = registries register_apply_action("my.apply", None, *fields, call=call) - entry = actions["my.apply"] - args = args or [] - template_arg = cg.TemplateArguments(*(t for t, _ in args)) - await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) - return entry + return await _run_entry(actions["my.apply"], config, args, platform) + + +async def _run_apply_condition( + registries: tuple[Registry, Registry], + check: str | ApplyCall, + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + platform: str = "esp32", +) -> RegistryEntry: + """Register an apply condition and run its builder with the given config.""" + _, conditions = registries + register_apply_condition("my.check", None, check) + return await _run_entry(conditions["my.check"], config, args, platform) def _apply_lambda(mock_cg: MockCodegen) -> str: @@ -774,6 +798,13 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) with pytest.raises(ValueError, match="'kd' is not in the schema"): register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + register_apply_condition( + "my.is", schema, ApplyCall("kp == {}", (("kp", cg.float_),)) + ) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_condition( + "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) + ) either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) for wrapped in ( @@ -805,3 +836,67 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( mock_cg ) + + +@pytest.mark.asyncio +async def test_register_apply_condition_predicate( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_condition( + registries, "is_playing()", {}, args=[(cg.int32, "x")] + ) + assert entry.type_id is ApplyCondition + condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args + assert condition_id == ID("obj_1") + assert str(template_arg) == "" + text = str(check) + assert text.startswith("[](const std::remove_cvref_t & x) -> bool {") + assert f"return ::{PARENT_OBJ}->is_playing();" in text + + +@pytest.mark.asyncio +async def test_apply_condition_compares_config_value( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + check = ApplyCall("state == {}", (("state", cg.bool_),)) + await _run_apply_condition(registries, check, {"state": True}) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_condition(registries, check, {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_condition_string_constant_is_a_plain_literal( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "expected", "called"), + [ + ("return x;", "->state == (x);", False), + ('return x.empty() ? "e" : x;', '->state == (x.empty() ? "e" : x);', False), + ('if (x.empty()) return "e";\nreturn x;', "}(x);", True), + ], +) +async def test_apply_condition_string_lambda_paths( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + body: str, + expected: str, + called: bool, +) -> None: + """A single return is inlined with no copy; a longer body is a called std::string lambda.""" + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition( + registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] + ) + text = _apply_lambda(mock_cg) + assert expected in text + assert ("-> std::string {" in text) is called From e1126a152e2fda099f10ec9b99c89ee41ffacc70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 00:30:54 +0100 Subject: [PATCH 404/759] [text_sensor] Use register_apply_condition for text_sensor.state (#19541) --- esphome/components/text_sensor/__init__.py | 14 ++------------ esphome/components/text_sensor/automation.h | 12 ------------ tests/components/text_sensor/common.yaml | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 35e33f237cc..34c46d5451b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -43,10 +43,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorStateCondition = text_sensor_ns.class_( - "TextSensorStateCondition", automation.Condition -) - FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) @@ -240,22 +236,16 @@ async def to_code(config): cg.add_global(text_sensor_ns.using) -@automation.register_condition( +automation.register_apply_condition( "text_sensor.state", - TextSensorStateCondition, cv.Schema( { cv.Required(CONF_ID): cv.use_id(TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), + automation.ApplyCall("state == {}", ((CONF_STATE, cg.std_string),)), ) -async def text_sensor_state_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - templ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(templ)) - return var FILTER_SOURCE_FILES = filter_source_files_from_defines( diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ac130afa331..ae632753e5b 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -22,16 +22,4 @@ class TextSensorStateRawTrigger final : public Trigger { } }; -template class TextSensorStateCondition final : public Condition { - public: - explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(std::string, state) - - bool check(const Ts &...x) override { return this->parent_->state == this->state_.value(x...); } - - protected: - TextSensor *parent_; -}; - } // namespace esphome::text_sensor diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml index ce1dd3a3f2b..efbf4e1dc7a 100644 --- a/tests/components/text_sensor/common.yaml +++ b/tests/components/text_sensor/common.yaml @@ -32,6 +32,23 @@ text_sensor: filters: - map: - ON -> Active + # text_sensor.state with a constant and with a lambda, inside a trigger + # whose argument is a std::string. + on_value: + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: Error + then: + - logger.log: substitute reports an error + - if: + condition: + text_sensor.state: + id: test_substitute_single + state: !lambda return x; + then: + - logger.log: substitute matches the mapped value - platform: template name: "Test Map Multiple" From 698eb71862ba12abae74a6014614330ffaae6395 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 10:03:33 +0100 Subject: [PATCH 405/759] [core] Add ESPHOME_strlen_P to the progmem helpers (#19574) --- esphome/components/noise/noise.cpp | 20 ++++---------------- esphome/core/progmem.h | 2 ++ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 48067061673..86ea1ec8747 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -2,16 +2,13 @@ #ifdef USE_NOISE #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::noise { static const char *const TAG = "noise"; @@ -74,22 +71,13 @@ size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *rea return 0; } buf[0] = HANDSHAKE_STATUS_REJECT; -#ifdef USE_STORE_LOG_STR_IN_FLASH - // On ESP8266 with flash strings, we need to use PROGMEM-aware functions - size_t reason_len = strlen_P(reinterpret_cast(reason)); - reason_len = std::min(reason_len, capacity - 1); - if (reason_len > 0) { - memcpy_P(buf + 1, reinterpret_cast(reason), reason_len); - } -#else + // The reason may live in PROGMEM on ESP8266; the progmem helpers read RAM and flash alike const char *reason_str = LOG_STR_ARG(reason); - size_t reason_len = strlen(reason_str); - reason_len = std::min(reason_len, capacity - 1); + size_t reason_len = std::min(ESPHOME_strlen_P(reason_str), capacity - 1); if (reason_len > 0) { // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string - std::memcpy(buf + 1, reason_str, reason_len); + progmem_memcpy(buf + 1, reason_str, reason_len); } -#endif return reason_len + 1; } diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 992b3c0e92f..28440fe8381 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -24,6 +24,7 @@ #define ESPHOME_strcasecmp_P strcasecmp_P #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P +#define ESPHOME_strlen_P strlen_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; // Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of @@ -47,6 +48,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strcasecmp_P strcasecmp #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp +#define ESPHOME_strlen_P strlen // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. From cd5ff787f47e35d739997e0a1154148f2d896f57 Mon Sep 17 00:00:00 2001 From: Philip Poten Date: Thu, 24 Sep 2026 11:18:32 +0200 Subject: [PATCH 406/759] [lvgl] Allow negative text_letter_space, text_line_space (#19576) --- esphome/components/lvgl/schemas.py | 4 ++-- tests/components/lvgl/lvgl-package.yaml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bbc977dca51..a546d9f1834 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -292,8 +292,8 @@ BASE_PROPS = { "LV_TEXT_DECOR_", "NONE", "UNDERLINE", "STRIKETHROUGH" ).several_of, "text_font": lv_font, - "text_letter_space": lvalid.lv_positive_int, - "text_line_space": lvalid.lv_positive_int, + "text_letter_space": lvalid.lv_int, + "text_line_space": lvalid.lv_int, "text_opa": lvalid.opacity, "text_outline_stroke_color": lvalid.lv_color, "text_outline_stroke_opa": lvalid.opacity, diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index bd2e77ee8c7..e10ca68ac64 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -560,6 +560,8 @@ lvgl: #endif return std::string(buf); align: top_left + text_letter_space: -4 + text_line_space: -4 - container: align: center anim_duration: 1s From 64127632f5aef589966e66f7538940860ca71e90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:11:50 +0100 Subject: [PATCH 407/759] [core][logger] Keep ESP8266 log strings in flash and deprecate the RAM option (#19577) --- esphome/components/logger/__init__.py | 21 ++++++++++++--- esphome/components/logger/log_buffer.h | 10 +++---- esphome/components/logger/logger.cpp | 7 +++-- esphome/components/logger/logger.h | 16 +++++------ esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_sensor.cpp | 2 +- .../prometheus/prometheus_handler.cpp | 6 ++++- esphome/components/script/script.cpp | 2 +- esphome/components/script/script.h | 2 +- esphome/core/log.cpp | 2 +- esphome/core/log.h | 11 ++++---- esphome/core/progmem.h | 11 ++------ tests/component_tests/logger/test_logger.py | 27 +++++++++++++++++++ .../logger/test_logger_ram_log_strings.yaml | 9 +++++++ 14 files changed, 87 insertions(+), 41 deletions(-) create mode 100644 tests/component_tests/logger/test_logger_ram_log_strings.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 138db75ad10..eb1f34565a2 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,3 +1,4 @@ +import logging import re from typing import Any @@ -63,6 +64,8 @@ from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] logger_ns = cg.esphome_ns.namespace("logger") LOG_LEVELS = { @@ -105,6 +108,7 @@ DEFAULT = "DEFAULT" CONF_INITIAL_LEVEL = "initial_level" CONF_LOGGER_ID = "logger_id" +CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONF_RUNTIME_TAG_LEVELS = "runtime_tag_levels" CONF_TASK_LOG_BUFFER_SIZE = "task_log_buffer_size" CONF_WAIT_FOR_CDC = "wait_for_cdc" @@ -219,6 +223,18 @@ def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: return config +def warn_ram_log_strings(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH) is False: + _LOGGER.warning( + "'%s: false' is ignored and will be rejected in 2027.4.0. Log format strings " + "always stay in flash now; copying them into RAM gave no speed gain and the " + "lost RAM caused crashes. Remove the option", + CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH, + ) + return config + + def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") @@ -232,7 +248,6 @@ LoggerMessageTrigger = logger_ns.class_( ) -CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -332,6 +347,7 @@ CONFIG_SCHEMA = cv.All( validate_local_no_higher_than_global, validate_initial_no_higher_than_global, validate_wait_for_cdc, + warn_ram_log_strings, ) @@ -450,9 +466,6 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_build_flag("-DCORE_DEBUG_LEVEL=5") if CORE.is_esp32 and is_at_least_very_verbose: cg.add_build_flag("-DENABLE_I2C_DEBUG_BUFFER") - if config.get(CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH): - cg.add_build_flag("-DUSE_STORE_LOG_STR_IN_FLASH") - if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) diff --git a/esphome/components/logger/log_buffer.h b/esphome/components/logger/log_buffer.h index 067ce04114f..14121d3dd9d 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -105,9 +105,9 @@ struct LogBuffer { this->format_vsnprintf_(format, args); this->finalize_(); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void HOT format_body_P(PGM_P format, va_list args) { - this->format_vsnprintf_P_(format, args); +#ifdef USE_ESP8266 + void HOT format_body_p(PGM_P format, va_list args) { + this->format_vsnprintf_p_(format, args); this->finalize_(); } #endif @@ -158,8 +158,8 @@ struct LogBuffer { return; this->process_vsnprintf_result_(vsnprintf(this->current_(), this->remaining_(), format, args)); } -#ifdef USE_STORE_LOG_STR_IN_FLASH - void format_vsnprintf_P_(PGM_P format, va_list args) { +#ifdef USE_ESP8266 + void format_vsnprintf_p_(PGM_P format, va_list args) { if (this->full_()) return; this->process_vsnprintf_result_(vsnprintf_P(this->current_(), this->remaining_(), format, args)); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index bfc005070e7..757ba6771e0 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -127,9 +127,8 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch } #endif // USE_ESP32 || USE_HOST || USE_LIBRETINY || USE_ZEPHYR -#ifdef USE_STORE_LOG_STR_IN_FLASH -// Implementation for ESP8266 with flash string support. -// Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. +#ifdef USE_ESP8266 +// ESP8266 keeps log format strings in flash. // // This function handles format strings stored in flash memory (PROGMEM) to save RAM. // Uses vsnprintf_P to read the format string directly from flash without copying to RAM. @@ -141,7 +140,7 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas this->log_message_to_buffer_and_send_(global_recursion_guard_, level, tag, line, format, args, nullptr); } -#endif // USE_STORE_LOG_STR_IN_FLASH +#endif // USE_ESP8266 inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index ae55f4145a9..6bdd5910f63 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -200,9 +200,9 @@ class Logger final : public Component { float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT -#ifdef USE_STORE_LOG_STR_IN_FLASH - void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, - va_list args); // NOLINT +#ifdef USE_ESP8266 + // NOLINTNEXTLINE(readability-identifier-naming) + void log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args); #endif protected: @@ -244,14 +244,14 @@ class Logger final : public Component { buf.format_body(format, args); } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 // Format a log message with flash string format and write it to a buffer with header, footer, and null terminator // ESP8266-only (single-task), thread_name is always nullptr - inline void HOT format_log_to_buffer_with_terminator_P_(uint8_t level, const char *tag, int line, + inline void HOT format_log_to_buffer_with_terminator_p_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args, LogBuffer &buf) { buf.write_header(level, tag, line, nullptr); - buf.format_body_P(reinterpret_cast(format), args); + buf.format_body_p(reinterpret_cast(format), args); } #endif @@ -283,9 +283,9 @@ class Logger final : public Component { FormatType format, va_list args, const char *thread_name) { RecursionGuard guard(recursion_guard); LogBuffer buf{this->tx_buffer_, ESPHOME_LOGGER_TX_BUFFER_SIZE}; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 if constexpr (std::is_same_v) { - this->format_log_to_buffer_with_terminator_P_(level, tag, line, format, args, buf); + this->format_log_to_buffer_with_terminator_p_(level, tag, line, format, args, buf); } else #endif { diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 7920187f922..d555358573b 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -50,7 +50,7 @@ bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 char buf[LOCK_STATE_STR_SIZE]; strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 1c0625d1c9e..0fa30ef937e 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -59,7 +59,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); #else root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 0412d8a8422..26b7c6ea803 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -903,7 +903,7 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); #else stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); @@ -947,7 +947,11 @@ void PrometheusHandler::climate_setting_row_(AsyncResponseStream *stream, climat stream->print(ESPHOME_F("\",category=\"")); stream->print(setting.c_str()); stream->print(ESPHOME_F("\",setting_value=\"")); +#ifdef USE_ESP8266 + stream->print((const __FlashStringHelper *) setting_value); +#else stream->print(LOG_STR_ARG(setting_value)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 61bca5bc281..6a35684da8c 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -5,7 +5,7 @@ namespace esphome::script { static const char *const TAG = "script"; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 63d0ff7cb3c..5e668999530 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -13,7 +13,7 @@ namespace esphome::script { class ScriptLogger { protected: -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 9fcddfeff61..2f1d5687588 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -36,7 +36,7 @@ void HOT esp_log_printf_(int level, const char *tag, int line, const char *forma #endif } -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 void HOT esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...) { #ifdef USE_LOGGER ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); diff --git a/esphome/core/log.h b/esphome/core/log.h index 14d24412ef6..362ed83b915 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -16,7 +16,7 @@ #include #include -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include "WString.h" #endif @@ -63,7 +63,8 @@ namespace esphome { void esp_log_printf_(int level, const char *tag, int line, const char *format, ...) // NOLINT __attribute__((format(printf, 4, 5))); -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 +// NOLINTNEXTLINE(readability-identifier-naming) void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT @@ -71,7 +72,7 @@ void esp_log_vprintf_(int level, const char *tag, int line, const char *format, int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #define ESPHOME_LOG_FORMAT(format) F(format) #else #define ESPHOME_LOG_FORMAT(format) format @@ -172,7 +173,7 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT // Helper class that identifies strings that may be stored in flash storage (similar to Arduino's __FlashStringHelper) struct LogString; -#ifdef USE_STORE_LOG_STR_IN_FLASH +#ifdef USE_ESP8266 #include @@ -181,7 +182,7 @@ struct LogString; #define LOG_STR(s) (reinterpret_cast(PSTR(s))) #define LOG_STR_LITERAL(s) LOG_STR_ARG(LOG_STR(s)) -#else // !USE_STORE_LOG_STR_IN_FLASH +#else // !USE_ESP8266 #define LOG_STR(s) (reinterpret_cast(s)) #define LOG_STR_ARG(s) (reinterpret_cast(s)) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 28440fe8381..54fc42dde16 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -27,17 +27,10 @@ #define ESPHOME_strlen_P strlen_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; -// Storage class for PROGMEM_STRING_TABLE data. Mirrors the logger's choice of -// LOG_STR_ARG: when LOG_STR_ARG treats the LogString as PROGMEM (PGM_P), the -// table data must actually be in flash; when LOG_STR_ARG treats it as a plain -// const char* (assumes RAM), the table data must live in RAM or non-logger -// consumers (ArduinoJson, Print, MQTT publish) crash on unaligned flash reads. -#ifdef USE_STORE_LOG_STR_IN_FLASH +// Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P +// on ESP8266, so the table data must be in flash to match. #define ESPHOME_PROGMEM_STRING_TABLE_STORAGE PROGMEM #else -#define ESPHOME_PROGMEM_STRING_TABLE_STORAGE -#endif -#else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * #define ESPHOME_PSTR(s) (s) diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 4ce30afb946..199d67ff5ca 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -1,7 +1,13 @@ """Tests for the logger component.""" +from collections.abc import Callable +from pathlib import Path import re +import pytest + +from esphome.core import CORE + def test_logger_pre_setup_before_other_components(generate_main): """Logger::pre_setup() must be called before any other component is created. @@ -84,3 +90,24 @@ def test_libretiny_uart0_is_emitted(generate_main): ) assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp + + +def test_ram_log_strings_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`false` is accepted but ignored: no RAM build flag, and a warning with the removal version.""" + generate_main("tests/component_tests/logger/test_logger_ram_log_strings.yaml") + + assert not any("STORE_LOG_STR" in flag for flag in CORE.build_flags) + assert "esp8266_store_log_strings_in_flash: false' is ignored" in caplog.text + assert "2027.4.0" in caplog.text + + +def test_flash_log_strings_default_does_not_warn( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + generate_main("tests/component_tests/logger/test_logger.yaml") + + assert "esp8266_store_log_strings_in_flash" not in caplog.text diff --git a/tests/component_tests/logger/test_logger_ram_log_strings.yaml b/tests/component_tests/logger/test_logger_ram_log_strings.yaml new file mode 100644 index 00000000000..ef6a8e2da17 --- /dev/null +++ b/tests/component_tests/logger/test_logger_ram_log_strings.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +logger: + esp8266_store_log_strings_in_flash: false From 4cbced407ddf12252895161a9d58e3b36bc7d835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:49:40 +0100 Subject: [PATCH 408/759] [ha-addon] Announce the MCP server to Home Assistant (#19413) --- .../etc/s6-overlay/s6-rc.d/discovery/run | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index bb36cfcdb4f..5b0ff67909e 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -4,7 +4,6 @@ # Home Assistant Add-on: ESPHome # Sends discovery information to Home Assistant. # ============================================================================== -declare config declare port # We only disable it when disabled explicitly @@ -19,14 +18,17 @@ port=$(bashio::addon.ingress_port) # Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 -config=$(\ - bashio::var.json \ - host "127.0.0.1" \ - port "^${port}" \ -) +# Send one discovery message; the config is a JSON string built with bashio::var.json. +send_discovery() { + local service=$1 + local config=$2 + if bashio::discovery "${service}" "${config}" > /dev/null; then + bashio::log.info "Successfully send ${service} discovery information to Home Assistant." + else + bashio::log.error "${service} discovery message to Home Assistant failed!" + fi +} -if bashio::discovery "esphome" "${config}" > /dev/null; then - bashio::log.info "Successfully send discovery information to Home Assistant." -else - bashio::log.error "Discovery message to Home Assistant failed!" -fi +send_discovery "esphome" "$(bashio::var.json host "127.0.0.1" port "^${port}")" +# The Device Builder MCP server, consumed by Home Assistant's mcp integration. +send_discovery "mcp" "$(bashio::var.json url "http://127.0.0.1:${port}/api/mcp")" From f24e1f31f2ba9f3e30d71237832ba05b9a3ff9d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:50:33 +0100 Subject: [PATCH 409/759] [rotary_encoder] Use register_apply_action for the set_value action (#19545) --- .../rotary_encoder/rotary_encoder.h | 12 ----------- esphome/components/rotary_encoder/sensor.py | 21 ++----------------- tests/components/rotary_encoder/common.yaml | 2 +- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 286267baed9..0d2f069c421 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" namespace esphome::rotary_encoder { @@ -106,15 +105,4 @@ class RotaryEncoderSensor final : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction final : public Action { - public: - RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} - TEMPLATABLE_VALUE(int, value) - - void play(const Ts &...x) override { this->encoder_->set_value(this->value_.value(x...)); } - - protected: - RotaryEncoderSensor *encoder_; -}; - } // namespace esphome::rotary_encoder diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 72722ec4b13..ab5442e6764 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -15,8 +15,6 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_STEPS, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") @@ -42,9 +40,6 @@ CONF_PUBLISH_INITIAL_VALUE = "publish_initial_value" RotaryEncoderSensor = rotary_encoder_ns.class_( "RotaryEncoderSensor", sensor.Sensor, cg.Component ) -RotaryEncoderSetValueAction = rotary_encoder_ns.class_( - "RotaryEncoderSetValueAction", automation.Action -) def validate_min_max_value(config: ConfigType) -> ConfigType: @@ -118,25 +113,13 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( +automation.register_apply_action( "sensor.rotary_encoder.set_value", - RotaryEncoderSetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(RotaryEncoderSensor), cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.int_), ) -async def sensor_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) - cg.add(var.set_value(template_)) - return var diff --git a/tests/components/rotary_encoder/common.yaml b/tests/components/rotary_encoder/common.yaml index 57ffdab8178..668cfac0e27 100644 --- a/tests/components/rotary_encoder/common.yaml +++ b/tests/components/rotary_encoder/common.yaml @@ -18,7 +18,7 @@ sensor: value: 10 - sensor.rotary_encoder.set_value: id: rotary_encoder1 - value: !lambda "return -1;" + value: !lambda "return -static_cast(x);" on_clockwise: - logger.log: Clockwise on_anticlockwise: From eae8ff7f911afa46058281353dc42dc086147456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:51:04 +0100 Subject: [PATCH 410/759] [audio_dac] Use register_apply_action for the mute_off, mute_on and set_volume actions (#19546) --- esphome/components/audio_dac/__init__.py | 49 +++++------------------ esphome/components/audio_dac/automation.h | 41 ------------------- tests/components/aic3204/common.yaml | 3 ++ 3 files changed, 14 insertions(+), 79 deletions(-) delete mode 100644 esphome/components/audio_dac/automation.h diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 1351793afd4..fbaa72014fc 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -2,9 +2,8 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_VOLUME +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -13,10 +12,6 @@ IS_PLATFORM_COMPONENT = True audio_dac_ns = cg.esphome_ns.namespace("audio_dac") AudioDac = audio_dac_ns.class_("AudioDac") -MuteOffAction = audio_dac_ns.class_("MuteOffAction", automation.Action) -MuteOnAction = audio_dac_ns.class_("MuteOnAction", automation.Action) -SetVolumeAction = audio_dac_ns.class_("SetVolumeAction", automation.Action) - MUTE_ACTION_SCHEMA = maybe_simple_id( { @@ -33,41 +28,19 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( - "audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True -) -async def audio_dac_mute_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("audio_dac.mute_off", "set_mute_off()"), + ("audio_dac.mute_on", "set_mute_on()"), +): + automation.register_apply_action( + _name, MUTE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -@automation.register_action( +automation.register_apply_action( "audio_dac.set_volume", - SetVolumeAction, SET_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.float_), ) -async def audio_dac_set_volume_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config.get(CONF_VOLUME), args, cg.float_) - cg.add(var.set_volume(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h deleted file mode 100644 index 9c5348271c2..00000000000 --- a/esphome/components/audio_dac/automation.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_dac.h" - -namespace esphome::audio_dac { - -template class MuteOffAction final : public Action { - public: - explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_off(); } - - protected: - AudioDac *audio_dac_; -}; - -template class MuteOnAction final : public Action { - public: - explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - void play(const Ts &...x) override { this->audio_dac_->set_mute_on(); } - - protected: - AudioDac *audio_dac_; -}; - -template class SetVolumeAction final : public Action { - public: - explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} - - TEMPLATABLE_VALUE(float, volume) - - void play(const Ts &...x) override { this->audio_dac_->set_volume(this->volume_.value(x...)); } - - protected: - AudioDac *audio_dac_; -}; - -} // namespace esphome::audio_dac diff --git a/tests/components/aic3204/common.yaml b/tests/components/aic3204/common.yaml index 5f175faee38..8cb10b24f8a 100644 --- a/tests/components/aic3204/common.yaml +++ b/tests/components/aic3204/common.yaml @@ -5,7 +5,10 @@ esphome: - audio_dac.mute_on: - audio_dac.set_volume: volume: 50% + - audio_dac.set_volume: + volume: !lambda return id(aic3204_dac).volume() * 0.5f; audio_dac: - platform: aic3204 + id: aic3204_dac i2c_id: i2c_bus From e98c4bd375879c4aee50fce7d50483abf2ea4b91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:51:30 +0100 Subject: [PATCH 411/759] [online_image] Use register_apply_action for the set_url and release actions (#19547) --- esphome/components/online_image/image.py | 44 +++++-------------- .../components/online_image/online_image.h | 30 +++---------- tests/components/online_image/common.yaml | 11 +++++ 3 files changed, 27 insertions(+), 58 deletions(-) diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index 3e9517937ed..a36f3a94947 100644 --- a/esphome/components/online_image/image.py +++ b/esphome/components/online_image/image.py @@ -14,8 +14,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) -from esphome.core import ID, Lambda -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType AUTO_LOAD = ["runtime_image"] @@ -31,14 +30,6 @@ OnlineImage = online_image_ns.class_( "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) - ONLINE_IMAGE_SCHEMA = ( runtime_image.runtime_image_schema(OnlineImage) .extend( @@ -90,31 +81,18 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +automation.register_apply_action( + "online_image.set_url", + SET_URL_SCHEMA, + automation.ApplyField(CONF_URL, "set_url", cg.std_string), + automation.ApplyField(CONF_UPDATE, "update_if", cg.bool_), ) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var +automation.register_apply_action( + "online_image.release", + RELEASE_IMAGE_SCHEMA, + automation.ApplyCall("release()"), +) _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 3e386f8cc8e..8b3485b8f74 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -43,6 +43,11 @@ class OnlineImage final : public PollingComponent, bool is_big_endian = false); void update() override; + /** Download now when `update` is true (the `update` flag of `online_image.set_url`). */ + void update_if(bool update) { + if (update) + this->update(); + } void loop() override; /** Set the URL to download the image from. */ @@ -104,29 +109,4 @@ class OnlineImage final : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction final : public Action { - public: - OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, url) - TEMPLATABLE_VALUE(bool, update) - void play(const Ts &...x) override { - this->parent_->set_url(this->url_.value(x...)); - if (this->update_.value(x...)) { - this->parent_->update(); - } - } - - protected: - OnlineImage *parent_; -}; - -template class OnlineImageReleaseAction final : public Action { - public: - OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->release(); } - - protected: - OnlineImage *parent_; -}; - } // namespace esphome::online_image diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index d8d04850cfd..df8f85596f8 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -84,3 +84,14 @@ esphome: id: online_rgba_image url: !lambda |- return str_sprintf("http://homeassistant.local:8123"); + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: false + - online_image.set_url: + id: online_rgba_image + url: http://www.example.org/example.png + update: !lambda return id(online_rgba_image).get_width() > 0; + - online_image.release: online_rgba_image + - online_image.release: + id: online_rgb24_image From 4477e35d66878bbafa0847d1a0b5e6d4eaff9dda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:52:34 +0100 Subject: [PATCH 412/759] [dfplayer] Use register_apply_action for the play, volume, eq, device and transport actions (#19549) --- esphome/components/dfplayer/__init__.py | 285 ++++-------------------- esphome/components/dfplayer/dfplayer.h | 99 +------- tests/components/dfplayer/common.yaml | 12 + 3 files changed, 57 insertions(+), 339 deletions(-) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index afe8c726759..d2120deaa61 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -37,23 +37,6 @@ DEVICE = { "TF_CARD": Device.TF_CARD, } -NextAction = dfplayer_ns.class_("NextAction", automation.Action) -PreviousAction = dfplayer_ns.class_("PreviousAction", automation.Action) -PlayMp3Action = dfplayer_ns.class_("PlayMp3Action", automation.Action) -PlayFileAction = dfplayer_ns.class_("PlayFileAction", automation.Action) -PlayFolderAction = dfplayer_ns.class_("PlayFolderAction", automation.Action) -SetVolumeAction = dfplayer_ns.class_("SetVolumeAction", automation.Action) -VolumeUpAction = dfplayer_ns.class_("VolumeUpAction", automation.Action) -VolumeDownAction = dfplayer_ns.class_("VolumeDownAction", automation.Action) -SetEqAction = dfplayer_ns.class_("SetEqAction", automation.Action) -SleepAction = dfplayer_ns.class_("SleepAction", automation.Action) -ResetAction = dfplayer_ns.class_("ResetAction", automation.Action) -StartAction = dfplayer_ns.class_("StartAction", automation.Action) -PauseAction = dfplayer_ns.class_("PauseAction", automation.Action) -StopAction = dfplayer_ns.class_("StopAction", automation.Action) -RandomAction = dfplayer_ns.class_("RandomAction", automation.Action) -SetDeviceAction = dfplayer_ns.class_("SetDeviceAction", automation.Action) - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -87,41 +70,30 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "dfplayer.play_next", - NextAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, +DFPLAYER_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(DFPlayer), + } ) -async def dfplayer_next_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("dfplayer.play_next", "next()"), + ("dfplayer.play_previous", "previous()"), + ("dfplayer.volume_up", "volume_up()"), + ("dfplayer.volume_down", "volume_down()"), + ("dfplayer.sleep", "sleep()"), + ("dfplayer.reset", "reset()"), + ("dfplayer.start", "start()"), + ("dfplayer.pause", "pause()"), + ("dfplayer.stop", "stop()"), + ("dfplayer.random", "random()"), +): + automation.register_apply_action( + _name, DFPLAYER_ACTION_SCHEMA, automation.ApplyCall(_call) + ) -@automation.register_action( - "dfplayer.play_previous", - PreviousAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_previous_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_mp3", - PlayMp3Action, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -129,70 +101,43 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyField(CONF_FILE, "play_mp3", cg.uint16), ) -async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - return var - -@automation.register_action( +# loop and file default to what the old action's unset templatable values evaluated to +automation.register_apply_action( "dfplayer.play", - PlayFileAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), }, key=CONF_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {})", ((CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)) + ), ) -async def dfplayer_play_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.play_folder", - PlayFolderAction, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), cv.Required(CONF_FOLDER): cv.templatable(cv.int_), - cv.Optional(CONF_FILE): cv.templatable(cv.int_), - cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), + cv.Optional(CONF_FILE, default=0): cv.templatable(cv.int_), + cv.Optional(CONF_LOOP, default=False): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyCall( + "play_folder({}, {}, {})", + ((CONF_FOLDER, cg.uint16), (CONF_FILE, cg.uint16), (CONF_LOOP, cg.bool_)), + ), ) -async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_FOLDER], args, cg.uint16) - cg.add(var.set_folder(template_)) - if CONF_FILE in config: - template_ = await cg.templatable(config[CONF_FILE], args, cg.uint16) - cg.add(var.set_file(template_)) - if CONF_LOOP in config: - template_ = await cg.templatable(config[CONF_LOOP], args, cg.bool_) - cg.add(var.set_loop(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_device", - SetDeviceAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -200,19 +145,11 @@ async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): }, key=CONF_DEVICE, ), - synchronous=True, + automation.ApplyField(CONF_DEVICE, "set_device", Device), ) -async def dfplayer_set_device_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_DEVICE], args, Device) - cg.add(var.set_device(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_volume", - SetVolumeAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -220,51 +157,11 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): }, key=CONF_VOLUME, ), - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_volume", cg.uint8), ) -async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.uint8) - cg.add(var.set_volume(template_)) - return var - -@automation.register_action( - "dfplayer.volume_up", - VolumeUpAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.volume_down", - VolumeDownAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( +automation.register_apply_action( "dfplayer.set_eq", - SetEqAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(DFPlayer), @@ -272,110 +169,8 @@ async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): }, key=CONF_EQ_PRESET, ), - synchronous=True, + automation.ApplyField(CONF_EQ_PRESET, "set_eq", EqPreset), ) -async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_EQ_PRESET], args, EqPreset) - cg.add(var.set_eq(template_)) - return var - - -@automation.register_action( - "dfplayer.sleep", - SleepAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_sleep_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.reset", - ResetAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_reset_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.start", - StartAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_start_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.pause", - PauseAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_pause_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.stop", - StopAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "dfplayer.random", - RandomAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(DFPlayer), - } - ), - synchronous=True, -) -async def dfplayer_random_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var def _default_enable(value: Any) -> Any: diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 66cf9294088..ad91dcfc94e 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -33,8 +33,13 @@ class DFPlayer final : public uart::UARTDevice, public Component { void play_mp3(uint16_t file); void play_file(uint16_t file); void play_file_loop(uint16_t file); + void play_file(uint16_t file, bool loop) { loop ? this->play_file_loop(file) : this->play_file(file); } void play_folder(uint16_t folder, uint16_t file); void play_folder_loop(uint16_t folder); + // The loop command plays the whole folder, so file is ignored when loop is set. + void play_folder(uint16_t folder, uint16_t file, bool loop) { + loop ? this->play_folder_loop(folder) : this->play_folder(folder, file); + } void volume_up(); void volume_down(); void set_device(Device device); @@ -72,100 +77,6 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -#define DFPLAYER_SIMPLE_ACTION(ACTION_CLASS, ACTION_METHOD) \ - template \ - class ACTION_CLASS : /* NOLINT */ \ - public Action, \ - public Parented { \ - void play(const Ts &...x) override { this->parent_->ACTION_METHOD(); } \ - }; - -DFPLAYER_SIMPLE_ACTION(NextAction, next) -DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) - -template class PlayMp3Action final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - this->parent_->play_mp3(file); - } -}; - -template class PlayFileAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_file_loop(file); - } else { - this->parent_->play_file(file); - } - } -}; - -template class PlayFolderAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, folder) - TEMPLATABLE_VALUE(uint16_t, file) - TEMPLATABLE_VALUE(bool, loop) - - void play(const Ts &...x) override { - auto folder = this->folder_.value(x...); - auto file = this->file_.value(x...); - auto loop = this->loop_.value(x...); - if (loop) { - this->parent_->play_folder_loop(folder); - } else { - this->parent_->play_folder(folder, file); - } - } -}; - -template class SetDeviceAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Device, device) - - void play(const Ts &...x) override { - auto device = this->device_.value(x...); - this->parent_->set_device(device); - } -}; - -template class SetVolumeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, volume) - - void play(const Ts &...x) override { - auto volume = this->volume_.value(x...); - this->parent_->set_volume(volume); - } -}; - -template class SetEqAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(EqPreset, eq) - - void play(const Ts &...x) override { - auto eq = this->eq_.value(x...); - this->parent_->set_eq(eq); - } -}; - -DFPLAYER_SIMPLE_ACTION(SleepAction, sleep) -DFPLAYER_SIMPLE_ACTION(ResetAction, reset) -DFPLAYER_SIMPLE_ACTION(StartAction, start) -DFPLAYER_SIMPLE_ACTION(PauseAction, pause) -DFPLAYER_SIMPLE_ACTION(StopAction, stop) -DFPLAYER_SIMPLE_ACTION(RandomAction, random) -DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) -DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) - template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } diff --git a/tests/components/dfplayer/common.yaml b/tests/components/dfplayer/common.yaml index 940f07a2ed3..317ffb99002 100644 --- a/tests/components/dfplayer/common.yaml +++ b/tests/components/dfplayer/common.yaml @@ -1,20 +1,32 @@ esphome: on_boot: then: + - dfplayer.play_mp3: 3 + - dfplayer.play_mp3: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" - dfplayer.play: 5 - dfplayer.play: file: 4 loop: true + - dfplayer.play: + file: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.play_folder: folder: 1 file: 3 - dfplayer.play_folder: folder: 1 loop: true + - dfplayer.play_folder: + folder: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;" + file: !lambda "return id(dfplayer_test).is_playing() ? 4 : 3;" + loop: !lambda return !id(dfplayer_test).is_playing(); - dfplayer.set_device: device: TF_CARD + - dfplayer.set_device: USB - dfplayer.set_volume: 5 + - dfplayer.set_volume: !lambda "return id(dfplayer_test).is_playing() ? 10 : 5;" - dfplayer.set_eq: ROCK + - dfplayer.set_eq: !lambda "return id(dfplayer_test).is_playing() ? dfplayer::JAZZ : dfplayer::BASS;" - dfplayer.play_next - dfplayer.play_previous - dfplayer.reset From 0ceefd5f916c0062a553a9cab666dbee26523a21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:53:29 +0100 Subject: [PATCH 413/759] [mqtt] Use register_apply_condition for mqtt.connected (#19561) --- esphome/components/mqtt/__init__.py | 9 ++------- esphome/components/mqtt/mqtt_client.h | 9 --------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 3050ceb1a4b..f4be6843180 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import logger, socket from esphome.components.esp32 import ( @@ -135,7 +134,6 @@ MQTTDisconnectTrigger = mqtt_ns.class_( "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) ) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) -MQTTConnectedCondition = mqtt_ns.class_("MQTTConnectedCondition", Condition) MQTTAlarmControlPanelComponent = mqtt_ns.class_( "MQTTAlarmControlPanelComponent", MQTTComponent @@ -599,18 +597,15 @@ async def register_mqtt_component(var, config): ) -@automation.register_condition( +automation.register_apply_condition( "mqtt.connected", - MQTTConnectedCondition, cv.Schema( { cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + "is_connected()", ) -async def mqtt_connected_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @automation.register_action( diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index fe0966e7255..2fd9a5a5502 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -417,15 +417,6 @@ template class MQTTPublishJsonAction final : public Action class MQTTConnectedCondition final : public Condition { - public: - MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->is_connected(); } - - protected: - MQTTClientComponent *parent_; -}; - template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} From 1671bb6dd35152b9a0c6d213205a56f64d28daa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:54:03 +0100 Subject: [PATCH 414/759] [time] Use register_apply_condition for time.has_time (#19560) --- esphome/components/time/__init__.py | 10 ++-------- esphome/components/time/real_time_clock.h | 9 --------- tests/components/time/common.yaml | 9 +++++++++ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index ecc448a96a7..cedb9b3c9b5 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -6,7 +6,6 @@ import logging import tzlocal from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_defines @@ -18,7 +17,6 @@ from esphome.const import ( CONF_DAYS_OF_WEEK, CONF_HOUR, CONF_HOURS, - CONF_ID, CONF_MINUTE, CONF_MINUTES, CONF_MONTHS, @@ -49,7 +47,6 @@ time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component) SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) -TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -469,18 +466,15 @@ async def to_code(config): cg.add_global(time_ns.using) -@automation.register_condition( +automation.register_apply_condition( "time.has_time", - TimeHasTimeCondition, cv.Schema( { cv.GenerateID(): cv.use_id(RealTimeClock), } ), + "now().is_valid()", ) -async def time_has_time_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index c449309c9ff..90994d90f41 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,13 +44,4 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition final : public Condition { - public: - TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} - bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } - - protected: - RealTimeClock *parent_; -}; - } // namespace esphome::time diff --git a/tests/components/time/common.yaml b/tests/components/time/common.yaml index cd258c7aa6d..776a67cc7a6 100644 --- a/tests/components/time/common.yaml +++ b/tests/components/time/common.yaml @@ -12,3 +12,12 @@ time: - logger.log: "CronTrigger fired (every 10 seconds)" - platform: sntp id: sntp_time + +esphome: + on_boot: + - if: + condition: + time.has_time: + id: sntp_time + then: + - logger.log: "sntp_time has valid time" From c39ffd709970dcc80c0cb2b8156fd1b1acbf9292 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:54:42 +0100 Subject: [PATCH 415/759] [lock] Use register_apply_condition for lock.is_locked and lock.is_unlocked (#19559) --- esphome/components/lock/__init__.py | 31 ++++++++-------------------- esphome/components/lock/automation.h | 13 ------------ tests/components/lock/common.yaml | 10 +++++++++ 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a4a7b5237d1..4ceb6206fd9 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -34,7 +34,6 @@ LockAction = lock_ns.class_("LockAction", automation.Action) OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockCondition = lock_ns.class_("LockCondition", Condition) LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") @@ -154,26 +153,14 @@ async def lock_action_to_code( return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) +automation.register_apply_condition( + "lock.is_locked", LOCK_ACTION_SCHEMA, f"state == {LockState.LOCK_STATE_LOCKED}" +) +automation.register_apply_condition( + "lock.is_unlocked", + LOCK_ACTION_SCHEMA, + f"state == {LockState.LOCK_STATE_UNLOCKED}", +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index ec6ead79f38..13153bc1df0 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -36,19 +36,6 @@ template class OpenAction final : public Action { Lock *lock_; }; -template class LockCondition final : public Condition { - public: - LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { - auto check_state = this->state_ ? LockState::LOCK_STATE_LOCKED : LockState::LOCK_STATE_UNLOCKED; - return this->parent_->state == check_state; - } - - protected: - Lock *parent_; - bool state_; -}; - /// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. template struct LockStateForwarder { diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 08001855cb1..2b360ce3fbf 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -4,6 +4,16 @@ esphome: - lock.lock: test_lock1 - lock.unlock: test_lock1 - lock.open: test_lock1 + - if: + condition: + lock.is_locked: test_lock1 + then: + - logger.log: locked + - if: + condition: + lock.is_unlocked: test_lock1 + then: + - logger.log: unlocked output: - platform: gpio From 1861d8282f37fe7fb5d10ef478281ba2a82296de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:55:12 +0100 Subject: [PATCH 416/759] [script] Use register_apply_condition for script.is_running (#19558) --- esphome/components/script/__init__.py | 9 ++------- esphome/components/script/script.h | 10 ---------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index e92850fd63a..6c16d002ad0 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -11,7 +11,6 @@ Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) ScriptStopAction = script_ns.class_("ScriptStopAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) -IsRunningCondition = script_ns.class_("IsRunningCondition", automation.Condition) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component) @@ -233,12 +232,8 @@ async def script_wait_action_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( +automation.register_apply_condition( "script.is_running", - IsRunningCondition, automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + "is_running()", ) -async def script_is_running_to_code(config, condition_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 5e668999530..3f988c9afa5 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -276,16 +276,6 @@ template class ScriptStopAction final : public Action class IsRunningCondition final : public Condition { - public: - explicit IsRunningCondition(C *parent) : parent_(parent) {} - - bool check(const Ts &...x) override { return this->parent_->is_running(); } - - protected: - C *parent_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. From 6768cb818bf92bf581ce23531fdb1b3f73db6a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:55:39 +0100 Subject: [PATCH 417/759] [media_player] Use register_apply_condition for the state and muted conditions (#19557) --- esphome/components/media_player/__init__.py | 48 ++++++++------------ esphome/components/media_player/automation.h | 37 --------------- 2 files changed, 18 insertions(+), 67 deletions(-) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index ed3eaf48537..d99e7877bf2 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -190,16 +190,15 @@ _STATE_TRIGGERS = ( (CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), ) -# State conditions that all share the same schema and codegen handler -_STATE_CONDITIONS = [ - "idle", - "paused", - "playing", - "announcing", - "on", - "off", - "muted", -] +# State conditions: (config_key suffix, checked state) +_STATE_CONDITIONS = ( + ("idle", MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), + ("paused", MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED), + ("playing", MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), + ("announcing", MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING), + ("on", MediaPlayerState.MEDIA_PLAYER_STATE_ON), + ("off", MediaPlayerState.MEDIA_PLAYER_STATE_OFF), +) MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) @@ -360,27 +359,16 @@ for _action_name in _COMMAND_ACTIONS: ) -def _snake_to_camel(name): - return "".join(word.capitalize() for word in name.split("_")) +for _condition_name, _state in _STATE_CONDITIONS: + automation.register_apply_condition( + f"media_player.is_{_condition_name}", + MEDIA_PLAYER_CONDITION_SCHEMA, + f"state == {_state}", + ) - -def _register_state_conditions(): - async def handler(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - for condition_name in _STATE_CONDITIONS: - class_name = f"Is{_snake_to_camel(condition_name)}Condition" - condition_class = media_player_ns.class_(class_name, automation.Condition) - automation.register_condition( - f"media_player.is_{condition_name}", - condition_class, - MEDIA_PLAYER_CONDITION_SCHEMA, - )(handler) - - -_register_state_conditions() +automation.register_apply_condition( + "media_player.is_muted", MEDIA_PLAYER_CONDITION_SCHEMA, "is_muted()" +) automation.register_apply_action( diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 648e9eb29f3..e4bb365c82a 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -27,41 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } -}; - -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } -}; - -template class IsPausedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } -}; - -template class IsAnnouncingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { - return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; - } -}; - -template class IsOnCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } -}; - -template class IsOffCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_muted(); } -}; - } // namespace esphome::media_player From e9aecb5fe8f33c3bb13ae54a85884b9057c63405 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Thu, 24 Sep 2026 09:56:11 -0300 Subject: [PATCH 418/759] [climate_ir] Store the supported modes in one mask (#19580) --- esphome/components/climate_ir/climate_ir.cpp | 18 +++-------- esphome/components/climate_ir/climate_ir.h | 32 ++++++++++++------- esphome/components/mitsubishi/mitsubishi.cpp | 2 +- esphome/components/mitsubishi/mitsubishi.h | 8 ++--- .../components/climate_ir/climate_ir_test.cpp | 29 ++++++++++++++++- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index ca639c7de0f..47143e6e607 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -13,17 +13,7 @@ climate::ClimateTraits ClimateIR::traits() { if (this->humidity_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } - traits.set_supported_modes({climate::CLIMATE_MODE_OFF}); - if (this->supports_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_COOL); - if (this->supports_heat_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT); - if (this->supports_heat_cool_) - traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL); - if (this->supports_dry_) - traits.add_supported_mode(climate::CLIMATE_MODE_DRY); - if (this->supports_fan_only_) - traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); + traits.set_supported_modes(this->modes_); traits.set_visual_min_temperature(this->minimum_temperature_); traits.set_visual_max_temperature(this->maximum_temperature_); @@ -98,8 +88,10 @@ void ClimateIR::dump_config() { " Supports HEAT: %s\n" " Supports COOL: %s\n" " Supports HEAT_COOL: %s", - this->minimum_temperature_, this->maximum_temperature_, YESNO(this->supports_heat_), - YESNO(this->supports_cool_), YESNO(this->supports_heat_cool_)); + this->minimum_temperature_, this->maximum_temperature_, + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_COOL)), + YESNO(this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL))); } } // namespace esphome::climate_ir diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index d65ed5d621b..060564b4ba3 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -30,8 +30,10 @@ class ClimateIR : public Component, this->minimum_temperature_ = minimum_temperature; this->maximum_temperature_ = maximum_temperature; this->temperature_step_ = temperature_step; - this->supports_dry_ = supports_dry; - this->supports_fan_only_ = supports_fan_only; + if (supports_dry) + this->modes_.insert(climate::CLIMATE_MODE_DRY); + if (supports_fan_only) + this->modes_.insert(climate::CLIMATE_MODE_FAN_ONLY); this->fan_modes_ = fan_modes; this->swing_modes_ = swing_modes; this->presets_ = presets; @@ -39,9 +41,11 @@ class ClimateIR : public Component, void setup() override; void dump_config() override; - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void set_supports_heat_cool(bool supports_heat_cool) { this->supports_heat_cool_ = supports_heat_cool; } + void set_supports_cool(bool supports_cool) { this->set_mode_supported_(climate::CLIMATE_MODE_COOL, supports_cool); } + void set_supports_heat(bool supports_heat) { this->set_mode_supported_(climate::CLIMATE_MODE_HEAT, supports_heat); } + void set_supports_heat_cool(bool supports_heat_cool) { + this->set_mode_supported_(climate::CLIMATE_MODE_HEAT_COOL, supports_heat_cool); + } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } @@ -59,12 +63,18 @@ class ClimateIR : public Component, // Dummy implement on_receive so implementation is optional for inheritors bool on_receive(remote_base::RemoteReceiveData data) override { return false; }; - bool supports_cool_{true}; - bool supports_heat_{true}; - // Default (supports_cool && supports_heat) is resolved during code generation. - bool supports_heat_cool_{true}; - bool supports_dry_{false}; - bool supports_fan_only_{false}; + ESPHOME_ALWAYS_INLINE void set_mode_supported_(climate::ClimateMode mode, bool supported) { + if (supported) { + this->modes_.insert(mode); + } else { + this->modes_.erase(mode); + } + } + + // The HEAT_COOL default (supports_cool && supports_heat) is resolved during code generation. + static constexpr climate::ClimateModeMask DEFAULT_MODES{climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}; + climate::ClimateModeMask modes_{DEFAULT_MODES}; climate::ClimateFanModeMask fan_modes_{}; climate::ClimateSwingModeMask swing_modes_{}; climate::ClimatePresetMask presets_{}; diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index d7c4e7f1a1d..9cb355fe9c1 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -102,7 +102,7 @@ void MitsubishiClimate::transmit_state() { default: remote_state[6] = MITSUBISHI_MODE_COOL; remote_state[8] = MITSUBISHI_MODE_A_COOL; - if (this->supports_heat_) { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) { remote_state[6] = MITSUBISHI_MODE_HEAT; remote_state[8] = MITSUBISHI_MODE_A_HEAT; } diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 5b61beac4e3..269d39b06b4 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -51,10 +51,10 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_SLEEP}) {} - void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } - void set_supports_dry(bool supports_dry) { this->supports_dry_ = supports_dry; } - void set_supports_fan_only(bool supports_fan_only) { this->supports_fan_only_ = supports_fan_only; } - void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } + void set_supports_dry(bool supports_dry) { this->set_mode_supported_(climate::CLIMATE_MODE_DRY, supports_dry); } + void set_supports_fan_only(bool supports_fan_only) { + this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; diff --git a/tests/components/climate_ir/climate_ir_test.cpp b/tests/components/climate_ir/climate_ir_test.cpp index 479e1f1b45d..25e894a360a 100644 --- a/tests/components/climate_ir/climate_ir_test.cpp +++ b/tests/components/climate_ir/climate_ir_test.cpp @@ -5,7 +5,8 @@ namespace esphome::climate_ir::testing { class TestClimateIR : public ClimateIR { public: - TestClimateIR() : ClimateIR(16.0f, 30.0f) {} + explicit TestClimateIR(bool supports_dry = false, bool supports_fan_only = false) + : ClimateIR(16.0f, 30.0f, 1.0f, supports_dry, supports_fan_only) {} using ClimateIR::traits; @@ -53,4 +54,30 @@ TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) { EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); } +TEST(ClimateIRTest, DefaultModes) { + TestClimateIR climate; + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, DryAndFanOnlyFromConstructor) { + TestClimateIR climate(true, true); + auto traits = climate.traits(); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_DRY)); + EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY)); +} + +TEST(ClimateIRTest, SetterCanTurnAModeBackOn) { + TestClimateIR climate; + climate.set_supports_cool(false); + EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); + climate.set_supports_cool(true); + EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL)); +} + } // namespace esphome::climate_ir::testing From 22959ea1cf888b6b8a5e1b26280b7c68b7fb0955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:56:40 +0100 Subject: [PATCH 419/759] [light] Use register_apply_condition for light.is_on and light.is_off (#19556) --- esphome/components/light/automation.h | 17 -------------- esphome/components/light/automation.py | 31 +++++++++----------------- esphome/components/light/types.py | 2 -- tests/components/light/common.yaml | 11 +++++++++ 4 files changed, 21 insertions(+), 40 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 5ce5f1ee601..2e963479f76 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -117,23 +117,6 @@ template class LightEffectCycleAction final : publ bool include_none_{false}; }; -template class LightIsOnCondition final : public Condition { - public: - explicit LightIsOnCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; -template class LightIsOffCondition final : public Condition { - public: - explicit LightIsOffCondition(LightState *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } - - protected: - LightState *state_; -}; - class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index d84df33a23d..5ae26607746 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -38,8 +38,6 @@ from .types import ( ColorMode, DimRelativeAction, LightEffectCycleAction, - LightIsOffCondition, - LightIsOnCondition, LightState, ToggleAction, ) @@ -381,24 +379,15 @@ async def light_addressable_set_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "light.is_on", - LightIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), +LIGHT_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(LightState), + } ) -@automation.register_condition( - "light.is_off", - LightIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(LightState), - } - ), + +automation.register_apply_condition( + "light.is_on", LIGHT_CONDITION_SCHEMA, "current_values.is_on()" +) +automation.register_apply_condition( + "light.is_off", LIGHT_CONDITION_SCHEMA, "current_values.is_on() == false" ) -async def light_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 4d2de314f64..ceb974f26d7 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -44,8 +44,6 @@ ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) -LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) -LightIsOffCondition = light_ns.class_("LightIsOffCondition", automation.Condition) # Triggers LightTurnOnTrigger = light_ns.class_( diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5ba245e7c33..5dc7091e400 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -68,6 +68,17 @@ esphome: - light.toggle: test_binary_light - light.turn_off: test_rgb_light + - if: + condition: + light.is_on: test_binary_light + then: + - logger.log: binary light is on + - if: + condition: + light.is_off: + id: test_rgb_light + then: + - logger.log: rgb light is off - light.turn_on: id: test_rgb_light brightness: 100% From 4780936867166993317d800270a15ebab6d35268 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:07 +0100 Subject: [PATCH 420/759] [fan] Use register_apply_condition for fan.is_on and fan.is_off (#19555) --- esphome/components/fan/__init__.py | 30 ++++++++--------------------- esphome/components/fan/automation.h | 17 ---------------- tests/components/fan/common.yaml | 24 ++++++++++++++++++++--- 3 files changed, 29 insertions(+), 42 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index d53357b7c17..d00375ae960 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -83,9 +83,6 @@ FanPresetSetTrigger = fan_ns.class_( "FanPresetSetTrigger", automation.Trigger.template(cg.StringRef) ) -FanIsOnCondition = fan_ns.class_("FanIsOnCondition", automation.Condition.template()) -FanIsOffCondition = fan_ns.class_("FanIsOffCondition", automation.Condition.template()) - _FAN_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -367,27 +364,16 @@ async def fan_cycle_speed_to_code(config, action_id, template_arg, args): return var -@automation.register_condition( - "fan.is_on", - FanIsOnCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), +FAN_CONDITION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(Fan), + } ) -@automation.register_condition( - "fan.is_off", - FanIsOffCondition, - automation.maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(Fan), - } - ), + +automation.register_apply_condition("fan.is_on", FAN_CONDITION_SCHEMA, "state") +automation.register_apply_condition( + "fan.is_off", FAN_CONDITION_SCHEMA, "state == false" ) -async def fan_is_on_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index f62a2dd8cde..5f0843581bc 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -68,23 +68,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -template class FanIsOnCondition final : public Condition { - public: - explicit FanIsOnCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->state; } - - protected: - Fan *state_; -}; -template class FanIsOffCondition final : public Condition { - public: - explicit FanIsOffCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->state; } - - protected: - Fan *state_; -}; - class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 3bef83cc42a..4867804394a 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -96,6 +96,20 @@ button: - fan.turn_on: id: test_fan speed: !lambda 'return 1;' + - platform: template + name: "Fan Is On Off" + on_press: + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_off: test_fan + - if: + condition: + fan.is_off: + id: test_fan + then: + - fan.turn_on: test_fan # Exercise fan.turn_on inside triggers with non-empty Ts: # - number.on_value: Ts = float (Python value type; previously raised @@ -111,6 +125,10 @@ number: step: 1 on_value: then: - - fan.turn_on: - id: test_fan - speed: !lambda "return (int) x;" + - if: + condition: + fan.is_on: test_fan + then: + - fan.turn_on: + id: test_fan + speed: !lambda "return (int) x;" From 5790181f44e669d5898726ff4dafb3fa4c20f588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:34 +0100 Subject: [PATCH 421/759] [binary_sensor] Use register_apply_condition for binary_sensor.is_on and binary_sensor.is_off (#19554) --- esphome/components/binary_sensor/__init__.py | 21 +++++-------------- esphome/components/binary_sensor/automation.h | 10 --------- tests/components/binary_sensor/common.yaml | 11 ++++++++++ 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 001a055d7cf..da0276c037d 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,7 +1,7 @@ from logging import getLogger from esphome import automation, core -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE @@ -138,9 +138,6 @@ BinarySensorInvalidateAction = binary_sensor_ns.class_( "BinarySensorInvalidateAction", automation.Action ) -# Condition -BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Condition) - # Filters Filter = binary_sensor_ns.class_("Filter") TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter) @@ -645,20 +642,12 @@ BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( ) -@automation.register_condition( - "binary_sensor.is_on", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_on", BINARY_SENSOR_CONDITION_SCHEMA, "state" ) -async def binary_sensor_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition( - "binary_sensor.is_off", BinarySensorCondition, BINARY_SENSOR_CONDITION_SCHEMA +automation.register_apply_condition( + "binary_sensor.is_off", BINARY_SENSOR_CONDITION_SCHEMA, "state == false" ) -async def binary_sensor_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1c331d22bdf..15a1a2c8a0a 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -155,16 +155,6 @@ class StateChangeTrigger final : public Trigger, optional > } }; -template class BinarySensorCondition final : public Condition { - public: - BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - BinarySensor *parent_; - bool state_; -}; - template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index f9eda80c3fd..d9939e7a14a 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -43,6 +43,17 @@ binary_sensor: - binary_sensor.template.publish: id: publish_target state: !lambda "return x.value_or(false);" + - if: + condition: + binary_sensor.is_on: publish_target + then: + - logger.log: "publish_target is on" + - if: + condition: + binary_sensor.is_off: + id: publish_target + then: + - logger.log: "publish_target is off" - platform: template id: publish_target From 8b11d3da39d702f85ec774c3dc592102ed670ba9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 13:57:58 +0100 Subject: [PATCH 422/759] [switch] Use register_apply_condition for switch.is_on and switch.is_off (#19553) --- esphome/components/switch/__init__.py | 18 +++++------------- esphome/components/switch/automation.h | 10 ---------- tests/components/switch/common.yaml | 10 ++++++++++ 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index e9d49b0ed01..d6cd061e040 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee import esphome.config_validation as cv @@ -54,7 +54,6 @@ RESTORE_MODES = { } -SwitchCondition = switch_ns.class_("SwitchCondition", Condition) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -201,17 +200,10 @@ for _name, _call in ( _name, SWITCH_ACTION_SCHEMA, automation.ApplyCall(_call) ) - -@automation.register_condition("switch.is_on", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_on_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, True) - - -@automation.register_condition("switch.is_off", SwitchCondition, SWITCH_ACTION_SCHEMA) -async def switch_is_off_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren, False) +automation.register_apply_condition("switch.is_on", SWITCH_ACTION_SCHEMA, "state") +automation.register_apply_condition( + "switch.is_off", SWITCH_ACTION_SCHEMA, "state == false" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ce81f53475d..8c7a3a725b8 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,16 +6,6 @@ namespace esphome::switch_ { -template class SwitchCondition final : public Condition { - public: - SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - Switch *parent_; - bool state_; -}; - class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { diff --git a/tests/components/switch/common.yaml b/tests/components/switch/common.yaml index 3ea235cfb91..ca187b30f1e 100644 --- a/tests/components/switch/common.yaml +++ b/tests/components/switch/common.yaml @@ -29,3 +29,13 @@ esphome: - switch.control: id: the_switch state: !lambda return (1 > 2); + - if: + condition: + switch.is_on: the_switch + then: + - logger.log: "Switch is on" + - if: + condition: + switch.is_off: the_switch + then: + - logger.log: "Switch is off" From 75360a5d523451d5b179c015f0fc5cec57c18907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:05:21 +0100 Subject: [PATCH 423/759] [core] Collapse ESP8266 flash string ifdefs onto the progmem helpers (#19578) --- .../components/api/api_frame_helper_noise.cpp | 15 ++------------- esphome/components/lock/lock.h | 3 --- esphome/components/mdns/mdns_component.h | 8 ++------ esphome/components/mqtt/mqtt_component.cpp | 6 +----- esphome/components/mqtt/mqtt_lock.cpp | 10 ++-------- esphome/components/mqtt/mqtt_sensor.cpp | 7 ++----- .../prometheus/prometheus_handler.cpp | 14 ++------------ .../components/prometheus/prometheus_handler.h | 8 ++------ esphome/components/script/script.cpp | 8 +------- esphome/components/script/script.h | 17 ++++------------- esphome/components/web_server/web_server.h | 9 +++------ 11 files changed, 21 insertions(+), 84 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 400cd1d9b86..e726a7c9676 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -5,16 +5,13 @@ #include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "proto.h" #include #include -#ifdef USE_ESP8266 -#include -#endif - namespace esphome::api { using noise::noise_err_to_logstr; @@ -26,11 +23,7 @@ static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, "api and noise component handshake size limits must match"); static const char *const TAG = "api.noise"; -#ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; -#else -static const char *const PROLOGUE_INIT = "NoiseAPIInit"; -#endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) @@ -72,11 +65,7 @@ APIError APINoiseFrameHelper::init() { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } -#ifdef USE_ESP8266 - memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#else - std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#endif + progmem_memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); state_ = State::CLIENT_HELLO; return APIError::OK; diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 86a9cdd3fbb..b32d0da8e74 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -32,9 +32,6 @@ enum LockState : uint8_t { }; const LogString *lock_state_to_string(LockState state); -/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 -static constexpr size_t LOCK_STATE_STR_SIZE = 10; - class LockTraits { public: LockTraits() = default; diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index ed06b8e1330..93d9eb1e385 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" // On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by // IP state listener events on whichever network interface is configured. #if (defined(USE_ESP8266) || defined(USE_RP2)) && \ @@ -39,12 +40,7 @@ struct MDNSString; // Macro to cast string literals to MDNSString* (works on all platforms) #define MDNS_STR(name) (reinterpret_cast(name)) -#ifdef USE_ESP8266 -#include -#define MDNS_STR_ARG(s) ((PGM_P) (s)) -#else -#define MDNS_STR_ARG(s) (reinterpret_cast(s)) -#endif +#define MDNS_STR_ARG(s) (reinterpret_cast(s)) // Service count is calculated at compile time by Python codegen // MDNS_SERVICE_COUNT will always be defined diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index a80cea6bd69..59a5d02d977 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -312,11 +312,7 @@ bool MQTTComponent::send_discovery_() { // Buffer sized for format string expansion: ~4 bytes net growth from format specifier to 8 hex digits, plus // safety margin char version_buf[sizeof(ver_fmt) + 8]; -#ifdef USE_ESP8266 - snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#else - snprintf(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); -#endif + ESPHOME_snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); device_info[MQTT_DEVICE_SW_VERSION] = version_buf; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index d555358573b..369c1c6668b 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -50,14 +50,8 @@ bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; -#ifdef USE_ESP8266 - char buf[LOCK_STATE_STR_SIZE]; - strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; - return this->publish(this->get_state_topic_to_(topic_buf), buf); -#else - return this->publish(this->get_state_topic_to_(topic_buf), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); -#endif + return this->publish(this->get_state_topic_to_(topic_buf), + reinterpret_cast(lock_state_to_string(this->lock_->state))); } } // namespace esphome::mqtt diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index 0fa30ef937e..a601a9bcc89 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -1,6 +1,7 @@ #include #include "mqtt_sensor.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "mqtt_const.h" @@ -59,11 +60,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_ESP8266 - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); -#else - root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); -#endif + root[MQTT_STATE_CLASS] = reinterpret_cast(state_class_to_string(this->sensor_->get_state_class())); } config.command_topic = false; diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 26b7c6ea803..6e51707ccd3 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -144,14 +144,8 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st } } -#ifdef USE_ESP8266 -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, - EntityBase *obj, std::string &area, std::string &node, - std::string &friendly_name) { -#else -void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name) { -#endif stream->print(metric_name); stream->print(ESPHOME_F("{id=\"")); stream->print(relabel_id_(obj).c_str()); @@ -903,11 +897,7 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); -#ifdef USE_ESP8266 - stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); -#else - stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); -#endif + stream->print(reinterpret_cast(valve::valve_operation_to_str(obj->current_operation))); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index bc256c68854..5574039e41f 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/progmem.h" #ifdef USE_CLIMATE #include "esphome/core/log.h" #endif @@ -68,13 +69,8 @@ class PrometheusHandler final : public AsyncWebHandler, public Component { void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); /// Print metric name and common labels (id, area, node, friendly_name, name) -#ifdef USE_ESP8266 - void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj, - std::string &area, std::string &node, std::string &friendly_name); -#else - void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area, + void print_metric_labels_(AsyncResponseStream *stream, ProgmemStr metric_name, EntityBase *obj, std::string &area, std::string &node, std::string &friendly_name); -#endif #ifdef USE_SENSOR /// Return the type for prometheus diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index 6a35684da8c..bfe96e1b23a 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -5,14 +5,8 @@ namespace esphome::script { static const char *const TAG = "script"; -#ifdef USE_ESP8266 -void ScriptLogger::esp_log_(int level, int line, const __FlashStringHelper *format, const char *param) { +void ScriptLogger::esp_log_(int level, int line, ProgmemStr format, const char *param) { esp_log_printf_(level, TAG, line, format, param); } -#else -void ScriptLogger::esp_log_(int level, int line, const char *format, const char *param) { - esp_log_printf_(level, TAG, line, format, param); -} -#endif } // namespace esphome::script diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 3f988c9afa5..d83555016de 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -8,28 +8,19 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::script { class ScriptLogger { protected: -#ifdef USE_ESP8266 - void esp_logw_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logw_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); } - void esp_logd_(int line, const __FlashStringHelper *format, const char *param) { + void esp_logd_(int line, ProgmemStr format, const char *param) { esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); } - void esp_log_(int level, int line, const __FlashStringHelper *format, const char *param); -#else - void esp_logw_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_WARN, line, format, param); - } - void esp_logd_(int line, const char *format, const char *param) { - esp_log_(ESPHOME_LOG_LEVEL_DEBUG, line, format, param); - } - void esp_log_(int level, int line, const char *format, const char *param); -#endif + void esp_log_(int level, int line, ProgmemStr format, const char *param); }; /// The abstract base class for all script types. diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 7aa4ac24a3b..3e50eeeec07 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -36,12 +37,8 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { -// Type for parameter names that can be stored in flash on ESP8266 -#ifdef USE_ESP8266 -using ParamNameType = const __FlashStringHelper *; -#else -using ParamNameType = const char *; -#endif +// Parameter names live in flash on ESP8266 +using ParamNameType = ProgmemStr; // All platforms need to defer actions to main loop thread. // Multi-core platforms need this for thread safety. From 993c96875cfd7e016c48d190cef23c519bc397ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:05:24 +0100 Subject: [PATCH 424/759] [core][select] Pass the select.set option as a plain literal (#19568) --- esphome/automation.py | 9 +++++++++ esphome/components/climate/__init__.py | 10 ++-------- esphome/components/select/__init__.py | 7 ++++++- tests/unit_tests/test_automation.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 850ac554af1..a8c421f065e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -224,6 +224,15 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.safe_exp(value)) +def literal_with_length(config: ConfigType, value: str) -> str: + """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. + + The target compares or copies the bytes in place, so it needs the RAM literal rather than + the PROGMEM rendering on ESP8266, and the length saves a strlen. + """ + return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 4c9f58c7a40..e64092d30e5 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -494,12 +494,6 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -def _literal_with_length(config: ConfigType, value: str) -> str: - # The (const char *, size_t) overload compares bytes in place, so it needs a plain - # literal rather than the default PROGMEM rendering on ESP8266, and skips a strlen. - return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" - - automation.register_apply_action( "climate.control", CLIMATE_CONTROL_ACTION_SCHEMA, @@ -517,14 +511,14 @@ automation.register_apply_action( CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_PRESET, "set_preset", ClimatePreset), automation.ApplyField( CONF_CUSTOM_PRESET, "set_preset", cg.std_string, - const_fn=_literal_with_length, + const_fn=automation.literal_with_length, ), automation.ApplyField(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), call="make_call", diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 2db4c8cc34b..6b7461bfa8d 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -142,7 +142,12 @@ automation.register_apply_action( cv.Required(CONF_OPTION): cv.templatable(cv.string_strict), } ), - automation.ApplyField(CONF_OPTION, "set_option", cg.std_string), + automation.ApplyField( + CONF_OPTION, + "set_option", + cg.std_string, + const_fn=automation.literal_with_length, + ), call="make_call", ) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c4265a78885..f6aaff3d926 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -18,6 +18,7 @@ from esphome.automation import ( TriggerOnTrueForwarder, build_callback_automations, has_non_synchronous_actions, + literal_with_length, maybe_simple_id, register_apply_action, register_apply_condition, @@ -838,6 +839,23 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_literal_with_length_is_plain_on_every_platform( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + """A (const char *, size_t) target gets the RAM literal and its byte length, never a flash copy.""" + fields = ( + ApplyField("option", "set_option", cg.std_string, const_fn=literal_with_length), + ) + await _run_apply_action( + registries, fields, {"option": "h\u00e9llo"}, platform=platform + ) + text = _apply_lambda(mock_cg) + assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text + assert "progmem_string" not in text + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen From afc5c29569b0793ba8b3c51b53c881612d805399 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:06:29 +0100 Subject: [PATCH 425/759] [event] Trigger events without building a temporary std::string (#19567) --- esphome/components/event/__init__.py | 9 ++++++++- esphome/components/event/event.cpp | 6 +++--- esphome/components/event/event.h | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index f6c30947244..d02863b9ee2 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -133,10 +133,17 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) +def _event_type_literal(config: ConfigType, value: str) -> str: + """A constant event type is a plain literal; trigger() only compares it, so no copy is needed.""" + return str(cg.safe_exp(value)) + + automation.register_apply_action( "event.trigger", TRIGGER_EVENT_SCHEMA, - automation.ApplyField(CONF_EVENT_TYPE, "trigger", cg.std_string), + automation.ApplyField( + CONF_EVENT_TYPE, "trigger", cg.std_string, _event_type_literal + ), ) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 673ccc98028..d4ac2644e3b 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -7,17 +7,17 @@ namespace esphome::event { static const char *const TAG = "event"; -void Event::trigger(const std::string &event_type) { +void Event::trigger(const char *event_type) { // Linear search with strcmp - faster than std::set for small datasets (1-5 items typical) const char *found = nullptr; for (const char *type : this->types_) { - if (strcmp(type, event_type.c_str()) == 0) { + if (strcmp(type, event_type) == 0) { found = type; break; } } if (found == nullptr) { - ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type.c_str()); + ESP_LOGE(TAG, "'%s': invalid event type for trigger(): %s", this->get_name().c_str(), event_type); return; } this->last_event_type_ = found; diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index e6fc7111c8b..374365c6fb9 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -21,7 +21,9 @@ namespace esphome::event { class Event : public EntityBase { public: - void trigger(const std::string &event_type); + /// Trigger an event; the type is matched against the configured types by string compare. + void trigger(const char *event_type); + void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } /// Set the event types supported by this event (from initializer list). void set_event_types(std::initializer_list event_types) { From cbb4c49011dafbf1f746c81dc24c29f96fc3c136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:07:07 +0100 Subject: [PATCH 426/759] [automation] Remove dead trigger classes from sensor, number and text_sensor (#19565) --- esphome/components/number/automation.h | 7 ------ esphome/components/sensor/automation.h | 14 ------------ esphome/components/text_sensor/automation.h | 25 --------------------- 3 files changed, 46 deletions(-) delete mode 100644 esphome/components/text_sensor/automation.h diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index f870acf8ddb..b3d9dfaffbe 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,13 +6,6 @@ namespace esphome::number { -class NumberStateTrigger final : public Trigger { - public: - explicit NumberStateTrigger(Number *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 08210634341..fdd20461e77 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,20 +6,6 @@ namespace esphome::sensor { -class SensorStateTrigger final : public Trigger { - public: - explicit SensorStateTrigger(Sensor *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - -class SensorRawStateTrigger final : public Trigger { - public: - explicit SensorRawStateTrigger(Sensor *parent) { - parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); - } -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h deleted file mode 100644 index ae632753e5b..00000000000 --- a/esphome/components/text_sensor/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::text_sensor { - -class TextSensorStateTrigger final : public Trigger { - public: - explicit TextSensorStateTrigger(TextSensor *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class TextSensorStateRawTrigger final : public Trigger { - public: - explicit TextSensorStateRawTrigger(TextSensor *parent) { - parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text_sensor From acc9324fa8cb853b042402e1ad234820721f6e18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:07:41 +0100 Subject: [PATCH 427/759] [tests] Give the binary_sensor and sensor publish targets distinct ids (#19564) --- tests/components/binary_sensor/common.yaml | 6 +++--- tests/components/sensor/common.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index d9939e7a14a..2ff0a47d77a 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -38,10 +38,10 @@ binary_sensor: args: ['x.has_value() ? ONOFF(x) : "Unknown"'] - binary_sensor.invalidate_state: binary_sensor_some_binary_sensor - binary_sensor.template.publish: - id: publish_target + id: binary_sensor_publish_target state: true - binary_sensor.template.publish: - id: publish_target + id: binary_sensor_publish_target state: !lambda "return x.value_or(false);" - if: condition: @@ -56,7 +56,7 @@ binary_sensor: - logger.log: "publish_target is off" - platform: template - id: publish_target + id: binary_sensor_publish_target name: "Publish Target" # Test autorepeat with default configuration (no timings) diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index b8f51835881..08d1ec0c196 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -7,15 +7,15 @@ sensor: update_interval: 1s on_value: - sensor.template.publish: - id: publish_target + id: sensor_publish_target state: 42.0 - sensor.template.publish: - id: publish_target + id: sensor_publish_target state: !lambda "return x * 2.0;" - platform: template name: "Publish Target" - id: publish_target + id: sensor_publish_target # Streaming filters (window_size == send_every) - uses StreamingFilter base class - platform: copy From 2ce2b7b25c93a1cc8f55aeb56579645f8332dd0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:09:35 +0100 Subject: [PATCH 428/759] [event] Remove the unused std::vector set_event_types overload (#19566) --- esphome/components/event/event.cpp | 8 -------- esphome/components/event/event.h | 8 ++------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index d4ac2644e3b..00bb9497f96 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -36,12 +36,4 @@ void Event::set_event_types(const FixedVector &event_types) { this->last_event_type_ = nullptr; // Reset when types change } -void Event::set_event_types(const std::vector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } - this->last_event_type_ = nullptr; // Reset when types change -} - } // namespace esphome::event diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index 374365c6fb9..f57327e33f0 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -3,7 +3,6 @@ #include #include #include -#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -25,20 +24,17 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event (from initializer list). + /// Set the event types supported by this event; called by generated code with string literals. void set_event_types(std::initializer_list event_types) { this->types_ = event_types; this->last_event_type_ = nullptr; // Reset when types change } - /// Set the event types supported by this event (from FixedVector). + /// Copy the event types of another event, for components that wrap one. void set_event_types(const FixedVector &event_types); - /// Set the event types supported by this event (from vector). - void set_event_types(const std::vector &event_types); // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; - void set_event_types(const std::vector &event_types) = delete; /// Return the event types supported by this event. const FixedVector &get_event_types() const { return this->types_; } From cbd985826c61260f696ca0cfc5e44bb305d0dcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:10:09 +0100 Subject: [PATCH 429/759] [speaker] Use register_apply_condition for speaker.is_playing and speaker.is_stopped (#19562) --- esphome/components/speaker/__init__.py | 23 ++++++----------------- esphome/components/speaker/automation.h | 10 ---------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index e62e4ccfd33..d0a743543e5 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -20,10 +20,6 @@ PlayAction = speaker_ns.class_( ) -IsPlayingCondition = speaker_ns.class_("IsPlayingCondition", automation.Condition) -IsStoppedCondition = speaker_ns.class_("IsStoppedCondition", automation.Condition) - - async def setup_speaker_core_(var, config): if audio_dac_config := config.get(CONF_AUDIO_DAC): aud_dac = await cg.get_variable(audio_dac_config) @@ -47,12 +43,6 @@ SPEAKER_AUTOMATION_SCHEMA = automation.maybe_simple_id( ) -async def speaker_action(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - @automation.register_action( "speaker.play", PlayAction, @@ -91,13 +81,12 @@ for _name, _call in ( _name, SPEAKER_AUTOMATION_SCHEMA, automation.ApplyCall(_call) ) -automation.register_condition( - "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) - -automation.register_condition( - "speaker.is_stopped", IsStoppedCondition, SPEAKER_AUTOMATION_SCHEMA -)(speaker_action) +automation.register_apply_condition( + "speaker.is_playing", SPEAKER_AUTOMATION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "speaker.is_stopped", SPEAKER_AUTOMATION_SCHEMA, "is_stopped()" +) automation.register_apply_action( diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 50097ab59a1..a44b79f05e3 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -38,14 +38,4 @@ template class PlayAction final : public Action, public P } data_; }; -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsStoppedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_stopped(); } -}; - } // namespace esphome::speaker From fed5dd6a9d3364cf57e28c88154e8532f0175a6f Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:40 -0400 Subject: [PATCH 430/759] =?UTF-8?q?[light]=20Accept=20the=20top-level=20wh?= =?UTF-8?q?ite=20key=20from=20Home=20Assistant's=20MQTT=20JSO=E2=80=A6=20(?= =?UTF-8?q?#19570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/light/light_json_schema.cpp | 7 ++ tests/components/light/__init__.py | 11 +++ .../light/test_light_json_schema.cpp | 74 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/components/light/__init__.py create mode 100644 tests/components/light/test_light_json_schema.cpp diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index aaa1176f9f8..8624fade16c 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -131,6 +131,13 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } + if (root[ESPHOME_F("white")].is()) { + // White stays full because ESPHome multiplies brightness and white + call.set_color_mode_if_supported(ColorMode::WHITE); + call.set_brightness(float(root[ESPHOME_F("white")]) / 255.0f); + call.set_white(1.0f); + } + if (root[ESPHOME_F("color_temp")].is()) { call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py new file mode 100644 index 00000000000..219b61dbf50 --- /dev/null +++ b/tests/components/light/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Enables light_json_schema.cpp without USE_MQTT, which pulls mqtt code into core/util.cpp + async def to_code_testing(config): + cg.add_define("USE_WEBSERVER") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/test_light_json_schema.cpp b/tests/components/light/test_light_json_schema.cpp new file mode 100644 index 00000000000..d7eb9ca56f5 --- /dev/null +++ b/tests/components/light/test_light_json_schema.cpp @@ -0,0 +1,74 @@ +#include + +#include "esphome/components/json/json_util.h" +#include "esphome/components/light/light_call.h" +#include "esphome/components/light/light_json_schema.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// An `rgbw` light with `color_interlock: true` +class InterlockedRgbwOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::RGB, ColorMode::WHITE}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class TestableLightCall : public LightCall { + public: + using LightCall::LightCall; + using LightCall::validate_; +}; + +LightColorValues parse(LightState &state, const char *payload) { + TestableLightCall call(&state); + json::parse_json(payload, [&](JsonObject root) { + LightJSONSchema::parse_json(state, call, root); + return true; + }); + return call.validate_(); +} + +} // namespace + +// HA's MQTT JSON schema selects the white color mode with a top-level `white` key +TEST(LightJSONSchema, TopLevelWhiteSelectsWhiteMode) { + InterlockedRgbwOutput output; + LightState state(&output); + state.remote_values.set_color_mode(ColorMode::RGB); + + auto values = parse(state, R"({"state":"ON","white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +TEST(LightJSONSchema, TopLevelWhiteOverridesBrightness) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","brightness":255,"white":128})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_brightness(), 128.0f / 255.0f); +} + +TEST(LightJSONSchema, ColorWStillSetsWhite) { + InterlockedRgbwOutput output; + LightState state(&output); + + auto values = parse(state, R"({"state":"ON","color":{"w":255}})"); + + EXPECT_EQ(values.get_color_mode(), ColorMode::WHITE); + EXPECT_FLOAT_EQ(values.get_white(), 1.0f); +} + +} // namespace esphome::light::testing From e92b1e27ca15768ca79bb0c57082234856e83721 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:12:04 +0100 Subject: [PATCH 431/759] [core] Let register_apply_action read the parent id from another schema key (#19548) --- esphome/automation.py | 20 ++++++++++------- tests/unit_tests/test_automation.py | 35 ++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index a8c421f065e..4a67cd31baf 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -339,9 +339,9 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] -async def _apply_parent(config: ConfigType) -> str: +async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: # Global-scope qualified so a trigger arg named like the id cannot shadow it. - return f"::{await cg.get_variable(config[CONF_ID])}" + return f"::{await cg.get_variable(config[id_key])}" def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: @@ -396,12 +396,14 @@ def register_apply_action( schema: cv.Schema, *fields: ApplyField | ApplyCall, call: str | None = None, + id_key: str = CONF_ID, ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. - Generates one stateless function for ``ApplyAction``: parent and constants are baked - in, lambdas are called inline with the trigger args. With ``call`` every statement targets - the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. + Generates one stateless function for ``ApplyAction``: the parent (read from + ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, + and ``apply_call.perform()`` is appended. """ # An action stores the value, so a std::string constant stays in flash on ESP8266. statements_spec = [ @@ -414,6 +416,7 @@ def register_apply_action( ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] + _check_key_in_schema(name, schema, id_key) for _, members in statements_spec: for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) @@ -424,7 +427,7 @@ def register_apply_action( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - parent = await _apply_parent(config) + parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] @@ -451,7 +454,7 @@ def register_apply_action( def register_apply_condition( - name: str, schema: cv.Schema, check: str | ApplyCall + name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID ) -> None: """Register a condition that is one expression on its parent, with no C++ class. @@ -463,6 +466,7 @@ def register_apply_condition( """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) members = call.members + _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) @@ -472,7 +476,7 @@ def register_apply_condition( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - parent = await _apply_parent(config) + parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) exprs = await _render_values( name, diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f6aaff3d926..1e1d2e714ab 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -610,12 +610,13 @@ async def _run_entry( config: dict[str, object], args: list[tuple[object, str]] | None, platform: str, + id_key: str = CONF_ID, ) -> RegistryEntry: """Run a registered builder with the given config, trigger args and platform.""" CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} args = args or [] template_arg = cg.TemplateArguments(*(t for t, _ in args)) - await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + await entry.fun({id_key: PARENT_ID, **config}, ID("obj_1"), template_arg, args) return entry @@ -626,11 +627,12 @@ async def _run_apply_action( args: list[tuple[object, str]] | None = None, call: str | None = None, platform: str = "esp32", + id_key: str = CONF_ID, ) -> RegistryEntry: """Register an apply action and run its builder with the given config.""" actions, _ = registries - register_apply_action("my.apply", None, *fields, call=call) - return await _run_entry(actions["my.apply"], config, args, platform) + register_apply_action("my.apply", None, *fields, call=call, id_key=id_key) + return await _run_entry(actions["my.apply"], config, args, platform, id_key) async def _run_apply_condition( @@ -639,11 +641,12 @@ async def _run_apply_condition( config: dict[str, object], args: list[tuple[object, str]] | None = None, platform: str = "esp32", + id_key: str = CONF_ID, ) -> RegistryEntry: """Register an apply condition and run its builder with the given config.""" _, conditions = registries - register_apply_condition("my.check", None, check) - return await _run_entry(conditions["my.check"], config, args, platform) + register_apply_condition("my.check", None, check, id_key=id_key) + return await _run_entry(conditions["my.check"], config, args, platform, id_key) def _apply_lambda(mock_cg: MockCodegen) -> str: @@ -663,6 +666,17 @@ async def test_register_apply_action_entry( assert str(template_arg) == "" +@pytest.mark.asyncio +async def test_apply_custom_id_key( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + await _run_apply_action(registries, (), {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + mock_cg.get_variable.reset_mock() + await _run_apply_condition(registries, "is_on()", {}, id_key="transmitter_id") + mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) + + @pytest.mark.asyncio async def test_apply_constants( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -806,6 +820,10 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_condition( "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) ) + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_action("my.bad_id", schema, id_key="parent_id") + with pytest.raises(ValueError, match="'parent_id' is not in the schema"): + register_apply_condition("my.bad_is_id", schema, "is_on()", id_key="parent_id") either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) for wrapped in ( @@ -818,7 +836,12 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_action( "my.bad", wrapped, ApplyField("kd", "set_kd", cg.float_) ) - nested = cv.Schema({cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_})}) + nested = cv.Schema( + { + cv.Required(CONF_ID): cv.string, + cv.Optional("v"): cv.Schema({cv.Optional("dir"): cv.int_}), + } + ) register_apply_action( "my.nested", nested, ApplyField(("v", "dir"), "set_dir", cg.int_) ) From 22177680c450984388405b8fb8883247c0030cd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:15:18 +0100 Subject: [PATCH 432/759] [tests] Point the binary_sensor conditions at the renamed publish target (#19583) --- tests/components/binary_sensor/common.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 2ff0a47d77a..a0368c44b97 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -45,13 +45,13 @@ binary_sensor: state: !lambda "return x.value_or(false);" - if: condition: - binary_sensor.is_on: publish_target + binary_sensor.is_on: binary_sensor_publish_target then: - logger.log: "publish_target is on" - if: condition: binary_sensor.is_off: - id: publish_target + id: binary_sensor_publish_target then: - logger.log: "publish_target is off" From b0c9a7d5bdfef10c4fc1fb3225f9896503cb3db6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:21:32 +0100 Subject: [PATCH 433/759] [core] Lint duplicate UNIT_/CONF_ constants against both const homes (#19422) --- esphome/components/as7341/sensor.py | 3 +- esphome/components/const/__init__.py | 1 + .../components/esp32_ble_server/__init__.py | 3 +- esphome/components/growatt_solar/sensor.py | 2 +- esphome/components/havells_solar/sensor.py | 2 +- esphome/components/ina2xx_base/__init__.py | 2 +- esphome/components/ltr390/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 2 +- esphome/components/ltr_als_ps/sensor.py | 2 +- esphome/components/lvgl/defines.py | 2 - esphome/components/ota/__init__.py | 2 +- esphome/components/selec_meter/sensor.py | 6 +- esphome/components/veml7700/sensor.py | 2 +- script/ci-custom.py | 74 ++++++++++++++++-- tests/script/test_ci_custom.py | 76 +++++++++++++++++++ 15 files changed, 157 insertions(+), 24 deletions(-) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index f70c5e999fd..3afab8422f9 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, @@ -33,8 +34,6 @@ CONF_F7 = "f7" CONF_F8 = "f8" CONF_NIR = "nir" -UNIT_COUNTS = "#" - AS7341_GAIN = as7341_ns.enum("AS7341Gain") GAIN_OPTIONS = { "X0.5": AS7341_GAIN.AS7341_GAIN_0_5X, diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 256ab5c0a3c..31b0a0c097e 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -61,3 +61,4 @@ ICON_SOLAR_POWER = "mdi:solar-power" KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" +UNIT_COUNTS = "#" diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 924d11db2b1..9355d588596 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -37,7 +37,6 @@ CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" CONF_CHARACTERISTICS = "characteristics" -CONF_DESCRIPTION = "description" CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 2e2b2187307..3728710b1f9 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, ) @@ -33,7 +34,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index dcea1afd04f..efb7af2c3cc 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_DEGREES, UNIT_HERTZ, + UNIT_KILOWATT_HOURS, UNIT_MINUTE, UNIT_VOLT, UNIT_VOLT_AMPS_REACTIVE, @@ -36,7 +37,6 @@ CONF_TOTAL_GENERATION_TIME = "total_generation_time" CONF_TODAY_GENERATION_TIME = "today_generation_time" CONF_PV1 = "pv1" CONF_PV2 = "pv2" -UNIT_KILOWATT_HOURS = "kWh" UNIT_HOURS = "h" UNIT_KOHM = "kΩ" UNIT_MILLIAMPERE = "mA" diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7bb589f0b15..474283ccce1 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, + UNIT_MILLIVOLT, UNIT_VOLT, UNIT_WATT, UNIT_WATT_HOURS, @@ -44,7 +45,6 @@ CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" CONF_RESET_ON_BOOT = "reset_on_boot" UNIT_COULOMB = "C" UNIT_JOULE = "J" -UNIT_MILLIVOLT = "mV" ina2xx_base_ns = cg.esphome_ns.namespace("ina2xx_base") INA2XX = ina2xx_base_ns.class_("INA2XX", cg.PollingComponent) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index c3ac90ad114..6c25729beec 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, @@ -28,7 +29,6 @@ CONF_UV_INDEX = "uv_index" CONF_UV = "uv" CONF_WINDOW_CORRECTION_FACTOR = "window_correction_factor" -UNIT_COUNTS = "#" UNIT_UVI = "UVI" LTR390GAIN = ltr390_ns.enum("LTR390GAIN") diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index c2091a6336b..e27a7c323ef 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -42,7 +43,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr501_ns = cg.esphome_ns.namespace("ltr501") diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index af09282e2d9..7bd476156ba 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -41,7 +42,6 @@ CONF_PS_LOW_THRESHOLD = "ps_low_threshold" ICON_BRIGHTNESS_7 = "mdi:brightness-7" ICON_GAIN = "mdi:multiplication" ICON_PROXIMITY = "mdi:hand-wave-outline" -UNIT_COUNTS = "#" ltr_als_ps_ns = cg.esphome_ns.namespace("ltr_als_ps") diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 73fc58736bb..f601b4b57a0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -680,7 +680,6 @@ CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" @@ -767,7 +766,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index a2e6953a162..6031a684894 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_STATE_CHANGE from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -38,7 +39,6 @@ CONF_ON_ABORT = "on_abort" CONF_ON_BEGIN = "on_begin" CONF_ON_END = "on_end" CONF_ON_PROGRESS = "on_progress" -CONF_ON_STATE_CHANGE = "on_state_change" ota_ns = cg.esphome_ns.namespace("ota") diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 0d4e0fafc50..04aef00d46b 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -27,6 +27,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_VOLT_AMPS_REACTIVE, @@ -44,9 +47,6 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" -UNIT_KILOWATT_HOURS = "kWh" -UNIT_KILOVOLT_AMPS_HOURS = "kVAh" -UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 4afca4b868f..02820505edb 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( CONF_ACTUAL_GAIN, @@ -31,7 +32,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] -UNIT_COUNTS = "#" ICON_MULTIPLICATION = "mdi:multiplication" ICON_BRIGHTNESS_7 = "mdi:brightness-7" diff --git a/script/ci-custom.py b/script/ci-custom.py index 6672760b06d..bdf7750ce8f 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -529,21 +529,67 @@ def lint_conf_matches(fname, match): CONF_RE = r'^(CONF_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' with codecs.open("esphome/const.py", "r", encoding="utf-8") as const_f_handle: constants_content = const_f_handle.read() +with codecs.open( + "esphome/components/const/__init__.py", "r", encoding="utf-8" +) as component_const_f_handle: + component_constants_content = component_const_f_handle.read() + +# The two canonical homes for shared constants: esphome/const.py (core, frozen) and +# esphome/components/const/__init__.py (shared by components). A constant defined in +# either must be imported from there rather than redefined in a component. +CONST_HOMES = ["esphome/const.py", "esphome/components/const/__init__.py"] + CONSTANTS = [m.group(1) for m in re.finditer(CONF_RE, constants_content, re.MULTILINE)] +COMPONENT_CONSTANTS = [ + m.group(1) for m in re.finditer(CONF_RE, component_constants_content, re.MULTILINE) +] CONSTANTS_USES = collections.defaultdict(list) -@lint_re_check(CONF_RE, include=["*.py"], exclude=["esphome/const.py"]) +def _const_home_error(name, core_constants, component_constants): + """Return an error if the constant already lives in one of the canonical homes.""" + if name in core_constants: + return ( + f"Constant {highlight(name)} has already been defined in const.py - " + "please import the constant from const.py directly." + ) + if name in component_constants: + return ( + f"Constant {highlight(name)} has already been defined in " + "esphome/components/const/__init__.py - please import the constant from " + "esphome.components.const directly." + ) + return None + + +@lint_re_check(CONF_RE, include=["*.py"], exclude=CONST_HOMES) def lint_conf_from_const_py(fname, match): name = match.group(1) - if name not in CONSTANTS: + err = _const_home_error(name, CONSTANTS, COMPONENT_CONSTANTS) + if err is None: CONSTANTS_USES[name].append(fname) - return None - return ( - f"Constant {highlight(name)} has already been defined in const.py - " - "please import the constant from const.py directly." - ) + return err + + +UNIT_RE = r'^(UNIT_[a-zA-Z0-9_]+)\s*=\s*[\'"].*?[\'"]\s*?$' +UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, constants_content, re.MULTILINE) +] +COMPONENT_UNIT_CONSTANTS = [ + m.group(1) for m in re.finditer(UNIT_RE, component_constants_content, re.MULTILINE) +] + +UNIT_CONSTANTS_USES = collections.defaultdict(list) + + +@lint_re_check(UNIT_RE, include=["*.py"], exclude=CONST_HOMES) +def lint_unit_from_const_py(fname, match): + name = match.group(1) + err = _const_home_error(name, UNIT_CONSTANTS, COMPONENT_UNIT_CONSTANTS) + if err is None: + UNIT_CONSTANTS_USES[name].append(fname) + return err RAW_PIN_ACCESS_RE = ( @@ -711,6 +757,20 @@ def lint_constants_usage(): return errs +@lint_post_check +def lint_unit_constants_usage(): + errs = [] + for constant, uses in UNIT_CONSTANTS_USES.items(): + if len(uses) < 3: + continue + errs.append( + f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " + f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " + "See https://developers.esphome.io/contributing/code/#python" + ) + return errs + + # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 5b4d4fe5c8b..be2c98051ff 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -151,6 +151,82 @@ def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") +# --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- + +# Real UNIT_ constants that live in each canonical home. +UNIT_IN_CONST_PY = ci_custom.UNIT_CONSTANTS[0] +UNIT_IN_COMPONENT_CONST = ci_custom.COMPONENT_UNIT_CONSTANTS[0] + + +def _unit_def(fname: str, content: str) -> list: + return ci_custom.lint_unit_from_const_py(fname, content) + + +def test_unit_already_in_const_py_is_flagged() -> None: + errs = _unit_def("esphome/components/x/sensor.py", f'{UNIT_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_unit_already_in_component_const_is_flagged() -> None: + errs = _unit_def( + "esphome/components/x/sensor.py", f'{UNIT_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_unit_not_in_const_py_is_tracked_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + assert _unit_def("a.py", 'UNIT_FOO_BAR = "fb"\n') == [] + assert ci_custom.UNIT_CONSTANTS_USES["UNIT_FOO_BAR"] == ["a.py"] + + +def test_unit_defined_in_three_files_is_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py", "c.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + errs = ci_custom.lint_unit_constants_usage() + assert any("UNIT_FOO_BAR" in e and "3 files" in e for e in errs) + + +def test_unit_defined_in_two_files_is_not_flagged() -> None: + ci_custom.UNIT_CONSTANTS_USES.clear() + for fname in ("a.py", "b.py"): + _unit_def(fname, 'UNIT_FOO_BAR = "fb"\n') + assert ci_custom.lint_unit_constants_usage() == [] + + +# --- same rule for CONF_, now also recognising the components/const home --- + +CONF_IN_CONST_PY = ci_custom.CONSTANTS[0] +CONF_IN_COMPONENT_CONST = ci_custom.COMPONENT_CONSTANTS[0] + + +def _conf_def(fname: str, content: str) -> list: + return ci_custom.lint_conf_from_const_py(fname, content) + + +def test_conf_already_in_const_py_is_flagged() -> None: + errs = _conf_def("esphome/components/x/sensor.py", f'{CONF_IN_CONST_PY} = "x"\n') + assert errs + assert "const.py" in errs[0][2] + + +def test_conf_already_in_component_const_is_flagged() -> None: + errs = _conf_def( + "esphome/components/x/sensor.py", f'{CONF_IN_COMPONENT_CONST} = "x"\n' + ) + assert errs + assert "esphome.components.const" in errs[0][2] + + +def test_conf_not_in_a_const_home_is_tracked_not_flagged() -> None: + ci_custom.CONSTANTS_USES.pop("CONF_FOO_BAR", None) + assert _conf_def("a.py", 'CONF_FOO_BAR = "foo_bar"\n') == [] + assert ci_custom.CONSTANTS_USES["CONF_FOO_BAR"] == ["a.py"] + + # --- ESP_LOG call scanner and bare-literal-ternary lint --- From da148bedfeaac13b76695a45bd1c04b75f1ee621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:25:50 +0100 Subject: [PATCH 434/759] [api] Pass the encode cursor by value through the protobuf helpers (#19014) --- esphome/components/api/api_connection.cpp | 7 +- .../components/api/api_connection_buffer.h | 8 +- esphome/components/api/api_pb2.cpp | 1230 +++++++++-------- esphome/components/api/proto.cpp | 6 + esphome/components/api/proto.h | 315 +++-- script/api_protobuf/api_protobuf.py | 188 ++- .../components/api/test_proto_mac_varint.cpp | 2 +- .../fixtures/api_encode_boundaries.yaml | 58 + tests/integration/state_utils.py | 54 +- .../integration/test_api_encode_boundaries.py | 76 + .../components/api/test_api_proto.py | 10 + .../api/test_api_protobuf_generator.py | 75 + 12 files changed, 1259 insertions(+), 770 deletions(-) create mode 100644 tests/integration/fixtures/api_encode_boundaries.yaml create mode 100644 tests/integration/test_api_encode_boundaries.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3064ff09b18..f69181b5274 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2267,7 +2267,12 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, // Capacity reserved above, cannot fail (void) shared_buf.resize(write_start + payload_size); ProtoWriteBuffer buffer{&shared_buf, write_start}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); +#ifdef ESPHOME_DEBUG_API + proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); +#else + (void) end; +#endif return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type); } // encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE) diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h index 08520249bf6..5fbe171b523 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -46,7 +46,13 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c return 0; } ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); +#ifdef ESPHOME_DEBUG_API + // A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes + proto_check_encode_end(end, shared_buf.data() + shared_buf.size()); +#else + (void) end; +#endif return total_calculated_size; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 7f162d9c159..c31cd3c6155 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -33,10 +33,10 @@ bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) } uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); return pos; } uint32_t HelloResponse::calculate_size() const { @@ -59,7 +59,7 @@ bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t va } uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); return pos; } uint32_t DisconnectRequest::calculate_size() const { @@ -70,8 +70,8 @@ uint32_t DisconnectRequest::calculate_size() const { #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); return pos; } uint32_t AreaInfo::calculate_size() const { @@ -84,9 +84,9 @@ uint32_t AreaInfo::calculate_size() const { #ifdef USE_DEVICES uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); return pos; } uint32_t DeviceInfo::calculate_size() const { @@ -100,9 +100,9 @@ uint32_t DeviceInfo::calculate_size() const { #ifdef USE_SERIAL_PROXY uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); return pos; } uint32_t SerialProxyInfo::calculate_size() const { @@ -115,66 +115,66 @@ uint32_t SerialProxyInfo::calculate_size() const { #endif uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); #ifdef USE_DEEP_SLEEP - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); #endif #ifdef USE_WEBSERVER - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); #endif - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address); #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); #endif #ifdef USE_DEVICES for (const auto &it : this->devices) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); } #endif #ifdef USE_AREAS for (const auto &it : this->areas) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); } #endif #ifdef USE_AREAS - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); #endif #ifdef USE_SERIAL_PROXY for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -246,8 +246,8 @@ uint32_t DeviceInfoResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); return pos; } uint32_t BluetoothProxyCapabilities::calculate_size() const { @@ -260,7 +260,7 @@ uint32_t BluetoothProxyCapabilities::calculate_size() const { #ifdef USE_VOICE_ASSISTANT uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); return pos; } uint32_t VoiceAssistantCapabilities::calculate_size() const { @@ -272,8 +272,8 @@ uint32_t VoiceAssistantCapabilities::calculate_size() const { #ifdef USE_ZWAVE_PROXY uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); return pos; } uint32_t ZWaveProxyCapabilities::calculate_size() const { @@ -286,17 +286,17 @@ uint32_t ZWaveProxyCapabilities::calculate_size() const { uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); #endif #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); #endif #ifdef USE_SERIAL_PROXY for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } #endif return pos; @@ -322,18 +322,18 @@ uint32_t DeviceCapabilitiesResponse::calculate_size() const { #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->is_status_binary_sensor); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->is_status_binary_sensor); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif return pos; } @@ -356,11 +356,11 @@ uint32_t ListEntitiesBinarySensorResponse::calculate_size() const { } uint8_t *BinarySensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -378,21 +378,21 @@ uint32_t BinarySensorStateResponse::calculate_size() const { #ifdef USE_COVER uint8_t *ListEntitiesCoverResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_position); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_tilt); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_tilt); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_stop); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_stop); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); #endif return pos; } @@ -418,12 +418,16 @@ uint32_t ListEntitiesCoverResponse::calculate_size() const { } uint8_t *CoverStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->position); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->tilt); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->current_operation)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + if (uint32_t raw = float_to_raw(this->position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + if (uint32_t raw = float_to_raw(this->tilt); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif return pos; } @@ -479,23 +483,23 @@ bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_FAN uint8_t *ListEntitiesFanResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_oscillation); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_speed); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_direction); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_speed_count); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_oscillation); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_speed); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_direction); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_speed_count); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); for (const char *it : *this->supported_preset_modes) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it)); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); #endif return pos; } @@ -525,14 +529,14 @@ uint32_t ListEntitiesFanResponse::calculate_size() const { } uint8_t *FanStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->oscillating); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->direction)); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->speed_level); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->preset_mode); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->oscillating); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->direction)); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->speed_level); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->preset_mode); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif return pos; } @@ -613,24 +617,28 @@ bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_LIGHT uint8_t *ListEntitiesLightResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); for (const auto &it : *this->supported_color_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it), true); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it)); + } + if (uint32_t raw = float_to_raw(this->min_mireds); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + if (uint32_t raw = float_to_raw(this->max_mireds); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->min_mireds); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->max_mireds); for (const char *it : *this->effects) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, this->disabled_by_default); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); #endif return pos; } @@ -661,21 +669,39 @@ uint32_t ListEntitiesLightResponse::calculate_size() const { } uint8_t *LightStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->brightness); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->color_mode)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->color_brightness); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->red); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->green); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->blue); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->white); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->color_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 12, this->cold_white); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 13, this->warm_white); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->effect); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + if (uint32_t raw = float_to_raw(this->brightness); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->color_mode)); + if (uint32_t raw = float_to_raw(this->color_brightness); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + if (uint32_t raw = float_to_raw(this->red); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + if (uint32_t raw = float_to_raw(this->green); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + if (uint32_t raw = float_to_raw(this->blue); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + if (uint32_t raw = float_to_raw(this->white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(this->color_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + if (uint32_t raw = float_to_raw(this->cold_white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 101, raw); + } + if (uint32_t raw = float_to_raw(this->warm_white); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 109, raw); + } + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->effect); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif return pos; } @@ -811,21 +837,21 @@ bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_SENSOR uint8_t *ListEntitiesSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->unit_of_measurement); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->accuracy_decimals); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->force_update); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->state_class)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->unit_of_measurement); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->accuracy_decimals); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->force_update); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->state_class)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif return pos; } @@ -853,11 +879,13 @@ __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + if (uint32_t raw = float_to_raw(this->state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -877,18 +905,18 @@ SensorStateResponse::calculate_size() const { #ifdef USE_SWITCH uint8_t *ListEntitiesSwitchResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif return pos; } @@ -911,10 +939,10 @@ uint32_t ListEntitiesSwitchResponse::calculate_size() const { } uint8_t *SwitchStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif return pos; } @@ -956,17 +984,17 @@ bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_TEXT_SENSOR uint8_t *ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif return pos; } @@ -988,11 +1016,11 @@ uint32_t ListEntitiesTextSensorResponse::calculate_size() const { } uint8_t *TextSensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -1024,10 +1052,10 @@ __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -1053,7 +1081,7 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD } uint8_t *NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); return pos; } uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { @@ -1065,8 +1093,8 @@ uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { #ifdef USE_API_HOMEASSISTANT_SERVICES uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->value); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->value); return pos; } uint32_t HomeassistantServiceMap::calculate_size() const { @@ -1077,25 +1105,25 @@ uint32_t HomeassistantServiceMap::calculate_size() const { } uint8_t *HomeassistantActionRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->service); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->service); for (auto &it : this->data) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } for (auto &it : this->data_template) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } for (auto &it : this->variables) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->is_event); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->call_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->wants_response); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->response_template); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->response_template); #endif return pos; } @@ -1166,9 +1194,9 @@ bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDe #ifdef USE_API_HOMEASSISTANT_STATES uint8_t *SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->entity_id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->attribute); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->once); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->entity_id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->attribute); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->once); return pos; } uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { @@ -1273,13 +1301,13 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_API_USER_DEFINED_ACTIONS uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); #endif return pos; } @@ -1297,14 +1325,14 @@ uint32_t ListEntitiesServicesArgument::calculate_size() const { } uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); for (auto &it : this->args) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); #endif return pos; } @@ -1430,11 +1458,11 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES uint8_t *ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); #endif return pos; } @@ -1452,16 +1480,16 @@ uint32_t ExecuteServiceResponse::calculate_size() const { #ifdef USE_CAMERA uint8_t *ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif return pos; } @@ -1482,11 +1510,11 @@ uint32_t ListEntitiesCameraResponse::calculate_size() const { } uint8_t *CameraImageResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -1517,48 +1545,54 @@ bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t v #ifdef USE_CLIMATE uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it), true); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it)); } - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->visual_min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->visual_max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->visual_target_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); + if (uint32_t raw = float_to_raw(this->visual_min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + if (uint32_t raw = float_to_raw(this->visual_max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + if (uint32_t raw = float_to_raw(this->visual_target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); for (const auto &it : *this->supported_fan_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it), true); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it)); } for (const auto &it : *this->supported_swing_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it), true); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it)); } for (const char *it : *this->supported_custom_fan_modes) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it)); } for (const auto &it : *this->supported_presets) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it), true); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it)); } for (const char *it : *this->supported_custom_presets) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesClimateResponse::calculate_size() const { @@ -1613,22 +1647,34 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { } uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->target_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->target_temperature_high); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 14, this->current_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 15, this->target_humidity); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); + if (uint32_t raw = float_to_raw(this->current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); + if (uint32_t raw = float_to_raw(this->current_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 117, raw); + } + if (uint32_t raw = float_to_raw(this->target_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 125, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); #endif return pos; } @@ -1748,25 +1794,31 @@ bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_WATER_HEATER uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->target_temperature_step); - for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); + if (uint32_t raw = float_to_raw(this->min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); + if (uint32_t raw = float_to_raw(this->max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + for (const auto &it : *this->supported_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it)); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { @@ -1794,16 +1846,24 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { } uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->target_temperature); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + if (uint32_t raw = float_to_raw(this->current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->target_temperature_high); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); + if (uint32_t raw = float_to_raw(this->target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(this->target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } return pos; } uint32_t WaterHeaterStateResponse::calculate_size() const { @@ -1864,22 +1924,28 @@ bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value #ifdef USE_NUMBER uint8_t *ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->min_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->max_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); + if (uint32_t raw = float_to_raw(this->min_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + if (uint32_t raw = float_to_raw(this->max_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(this->step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); #endif return pos; } @@ -1906,11 +1972,13 @@ uint32_t ListEntitiesNumberResponse::calculate_size() const { } uint8_t *NumberStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + if (uint32_t raw = float_to_raw(this->state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -1953,19 +2021,19 @@ bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_SELECT uint8_t *ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif for (const char *it : *this->options) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif return pos; } @@ -1991,11 +2059,11 @@ uint32_t ListEntitiesSelectResponse::calculate_size() const { } uint8_t *SelectStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -2046,21 +2114,21 @@ bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_SIREN uint8_t *ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); for (const char *it : *this->tones) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif return pos; } @@ -2088,10 +2156,10 @@ uint32_t ListEntitiesSirenResponse::calculate_size() const { } uint8_t *SirenStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif return pos; } @@ -2162,20 +2230,20 @@ bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_LOCK uint8_t *ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif return pos; } @@ -2200,10 +2268,10 @@ uint32_t ListEntitiesLockResponse::calculate_size() const { } uint8_t *LockStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif return pos; } @@ -2259,17 +2327,17 @@ bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_BUTTON uint8_t *ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif return pos; } @@ -2315,11 +2383,11 @@ bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_MEDIA_PLAYER uint8_t *MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); return pos; } uint32_t MediaPlayerSupportedFormat::calculate_size() const { @@ -2333,21 +2401,21 @@ uint32_t MediaPlayerSupportedFormat::calculate_size() const { } uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); for (auto &it : this->supported_formats) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); return pos; } uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { @@ -2373,12 +2441,14 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { } uint8_t *MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + if (uint32_t raw = float_to_raw(this->volume); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); #endif return pos; } @@ -2465,20 +2535,20 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + pos = ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); *len_pos = static_cast(pos - len_pos - 1); } return pos; @@ -2520,10 +2590,10 @@ bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value } uint8_t *BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); return pos; } uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { @@ -2547,11 +2617,11 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_var uint8_t *BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); return pos; } uint32_t BluetoothGATTDescriptor::calculate_size() const { @@ -2567,15 +2637,15 @@ uint32_t BluetoothGATTDescriptor::calculate_size() const { uint8_t *BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); for (auto &it : this->descriptors) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); return pos; } uint32_t BluetoothGATTCharacteristic::calculate_size() const { @@ -2597,14 +2667,14 @@ uint32_t BluetoothGATTCharacteristic::calculate_size() const { uint8_t *BluetoothGATTService::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); for (auto &it : this->characteristics) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); return pos; } uint32_t BluetoothGATTService::calculate_size() const { @@ -2624,9 +2694,9 @@ uint32_t BluetoothGATTService::calculate_size() const { } uint8_t *BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); for (auto &it : this->services) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } return pos; } @@ -2642,7 +2712,7 @@ uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { } uint8_t *BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); return pos; } uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { @@ -2665,9 +2735,9 @@ bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_val } uint8_t *BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); return pos; } uint32_t BluetoothGATTReadResponse::calculate_size() const { @@ -2761,9 +2831,9 @@ bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_v } uint8_t *BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); return pos; } uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { @@ -2775,11 +2845,11 @@ uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { } uint8_t *BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); for (const auto &it : this->allocated) { if (it != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } } return pos; @@ -2797,9 +2867,9 @@ uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { } uint8_t *BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); return pos; } uint32_t BluetoothGATTErrorResponse::calculate_size() const { @@ -2811,8 +2881,8 @@ uint32_t BluetoothGATTErrorResponse::calculate_size() const { } uint8_t *BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); return pos; } uint32_t BluetoothGATTWriteResponse::calculate_size() const { @@ -2823,8 +2893,8 @@ uint32_t BluetoothGATTWriteResponse::calculate_size() const { } uint8_t *BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); return pos; } uint32_t BluetoothGATTNotifyResponse::calculate_size() const { @@ -2835,9 +2905,9 @@ uint32_t BluetoothGATTNotifyResponse::calculate_size() const { } uint8_t *BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); return pos; } uint32_t BluetoothDevicePairingResponse::calculate_size() const { @@ -2849,9 +2919,9 @@ uint32_t BluetoothDevicePairingResponse::calculate_size() const { } uint8_t *BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); return pos; } uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { @@ -2863,9 +2933,9 @@ uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { } uint8_t *BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); return pos; } uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { @@ -2879,9 +2949,9 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { #ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); return pos; } uint32_t BluetoothScannerStateResponse::calculate_size() const { @@ -2918,9 +2988,11 @@ bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_vari } uint8_t *VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume_multiplier); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); + if (uint32_t raw = float_to_raw(this->volume_multiplier); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } return pos; } uint32_t VoiceAssistantAudioSettings::calculate_size() const { @@ -2932,11 +3004,11 @@ uint32_t VoiceAssistantAudioSettings::calculate_size() const { } uint8_t *VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); return pos; } uint32_t VoiceAssistantRequest::calculate_size() const { @@ -3026,9 +3098,9 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited } uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); return pos; } uint32_t VoiceAssistantAudio::calculate_size() const { @@ -3103,7 +3175,7 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength } uint8_t *VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); return pos; } uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { @@ -3113,10 +3185,10 @@ uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { } uint8_t *VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); for (auto &it : this->trained_languages) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } @@ -3185,12 +3257,12 @@ bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoL uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (auto &it : this->available_wake_words) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); } for (const auto &it : *this->active_wake_words) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, it, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); return pos; } uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { @@ -3222,19 +3294,19 @@ bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengt #ifdef USE_ALARM_CONTROL_PANEL uint8_t *ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif return pos; } @@ -3258,10 +3330,10 @@ uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { } uint8_t *AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif return pos; } @@ -3314,20 +3386,20 @@ bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit #ifdef USE_TEXT uint8_t *ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif return pos; } @@ -3352,11 +3424,11 @@ uint32_t ListEntitiesTextResponse::calculate_size() const { } uint8_t *TextStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -3407,16 +3479,16 @@ bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_DATETIME_DATE uint8_t *ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif return pos; } @@ -3437,13 +3509,13 @@ uint32_t ListEntitiesDateResponse::calculate_size() const { } uint8_t *DateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif return pos; } @@ -3494,16 +3566,16 @@ bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_DATETIME_TIME uint8_t *ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif return pos; } @@ -3524,13 +3596,13 @@ uint32_t ListEntitiesTimeResponse::calculate_size() const { } uint8_t *TimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); #endif return pos; } @@ -3581,20 +3653,20 @@ bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_EVENT uint8_t *ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); for (const char *it : *this->event_types) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it), true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it)); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); #endif return pos; } @@ -3621,10 +3693,10 @@ uint32_t ListEntitiesEventResponse::calculate_size() const { } uint8_t *EventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); #endif return pos; } @@ -3641,20 +3713,20 @@ uint32_t EventResponse::calculate_size() const { #ifdef USE_VALVE uint8_t *ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); #endif return pos; } @@ -3679,11 +3751,13 @@ uint32_t ListEntitiesValveResponse::calculate_size() const { } uint8_t *ValveStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->position); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + if (uint32_t raw = float_to_raw(this->position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -3732,16 +3806,16 @@ bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_DATETIME_DATETIME uint8_t *ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); #endif return pos; } @@ -3762,11 +3836,13 @@ uint32_t ListEntitiesDateTimeResponse::calculate_size() const { } uint8_t *DateTimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->epoch_seconds); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + if (uint32_t raw = this->epoch_seconds; raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); #endif return pos; } @@ -3809,17 +3885,17 @@ bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { #ifdef USE_UPDATE uint8_t *ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); #endif return pos; } @@ -3841,18 +3917,20 @@ uint32_t ListEntitiesUpdateResponse::calculate_size() const { } uint8_t *UpdateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->progress); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); + if (uint32_t raw = float_to_raw(this->progress); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); #endif return pos; } @@ -3916,7 +3994,7 @@ __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -3950,8 +4028,8 @@ bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited va } uint8_t *ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); return pos; } uint32_t ZWaveProxyRequest::calculate_size() const { @@ -3962,8 +4040,8 @@ uint32_t ZWaveProxyRequest::calculate_size() const { } uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); return pos; } uint32_t ZWaveProxyRequestResponse::calculate_size() const { @@ -3976,19 +4054,19 @@ uint32_t ZWaveProxyRequestResponse::calculate_size() const { #ifdef USE_INFRARED uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); return pos; } uint32_t ListEntitiesInfraredResponse::calculate_size() const { @@ -4059,11 +4137,11 @@ uint8_t * InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); #endif - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); for (const auto &it : *this->timings) { - ProtoEncode::encode_sint32(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_sint32_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } @@ -4086,21 +4164,21 @@ InfraredRFReceiveEvent::calculate_size() const { #ifdef USE_RADIO_FREQUENCY uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); return pos; } uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { @@ -4153,8 +4231,8 @@ __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) @@ -4212,9 +4290,9 @@ bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_vari } uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); return pos; } uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { @@ -4239,10 +4317,10 @@ bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t v } uint8_t *SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); return pos; } uint32_t SerialProxyRequestResponse::calculate_size() const { @@ -4292,8 +4370,8 @@ bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto } uint8_t *BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); return pos; } uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 236e4a474a1..61ac5f77015 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -195,6 +195,12 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t * ESP_LOGE(TAG, "Proto encode bounds check failed in %s: need %zu bytes, %td available", caller, bytes, end - pos); abort(); } +void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) { + if (end == expected) + return; + ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected); + abort(); +} void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) { ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes, diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index a226e080e8c..fab5194edce 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -221,6 +221,9 @@ class Proto32Bit { proto_check_bounds_failed(pos, n, proto_debug_end_, __builtin_FUNCTION()); \ } while (0) void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *end, const char *caller); +/// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than +/// assert(), so NDEBUG cannot switch it off. +void proto_check_encode_end(const uint8_t *end, const uint8_t *expected); #else #define PROTO_ENCODE_DEBUG_PARAM #define PROTO_ENCODE_DEBUG_ARG @@ -287,19 +290,31 @@ class ProtoWriteBuffer { uint8_t *pos_; }; +// A four byte unaligned store is a memcpy call on ESP-IDF (-fno-builtin-memcpy) and on ARM cores without +// unaligned access (Cortex-M0+, ARM9), so those targets share one outlined byte store helper per fixed32 +// field. Elsewhere the write inlines to a single store, or on ESP8266 to a few stores that measured +// faster than a call, so it stays inline. +#if defined(USE_ESP32) || (defined(__arm__) && !defined(__ARM_FEATURE_UNALIGNED)) +#define PROTO_OUTLINE_FOR_SIZE __attribute__((noinline)) +#define PROTO_FIXED32_BYTE_STORES true +#else +#define PROTO_OUTLINE_FOR_SIZE inline +#define PROTO_FIXED32_BYTE_STORES false +#endif + // Varint encoding thresholds — used by both proto_encode_* free functions and ProtoSize. constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128 constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384 -/// Static encode helpers for generated encode() functions. -/// Generated code hoists buffer.pos_ into a local uint8_t *__restrict__ pos, -/// then calls these methods which take pos by reference. No struct, no overhead. -/// For sub-messages, pos is synced back to buffer before the call and reloaded after. +/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and +/// returns it advanced, so outlined calls at -Os chain through the return register instead of a +/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default. class ProtoEncode { public: /// Write a multi-byte varint directly through a pos pointer. template - static inline void encode_varint_raw_loop(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, T value) { + [[nodiscard]] static inline uint8_t *encode_varint_raw_loop(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + T value) { do { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value | 0x80); @@ -307,48 +322,49 @@ class ProtoEncode { } while (value > 0x7F); PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); + return pos; } - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } /// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_short(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } if (value < VARINT_MAX_2_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 2); *pos++ = static_cast(value | 0x80); *pos++ = static_cast(value >> 7); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint64_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { if (value < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = static_cast(value); - return; + return pos; } - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } /// Encode a 48-bit MAC address (stored in a uint64) as varint. /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes /// with no per-byte branch. Falls back to the general loop otherwise. /// Caller must guarantee value fits in 48 bits (checked in debug builds). - static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint64_t value) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_varint_raw_48bit(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) { #ifdef ESPHOME_DEBUG_API assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits"); #endif @@ -363,38 +379,39 @@ class ProtoEncode { pos[4] = static_cast((value >> 28) | 0x80); pos[5] = static_cast((value >> 35) | 0x80); pos[6] = static_cast(value >> 42); - pos += 7; - return; + return pos + 7; } - encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); } - static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint32_t field_id, uint32_t type) { - encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_field_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { + return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); } /// Write a single precomputed tag byte. Tag must be < 128. - static inline void ESPHOME_ALWAYS_INLINE write_raw_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint8_t b) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + write_raw_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t b) { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = b; + return pos; } /// Reserve one byte for later backpatch (e.g., sub-message length). /// Advances pos past the reserved byte without writing a value. - static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + reserve_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); - pos++; + return pos + 1; } /// Write raw bytes to the buffer (no tag, no length prefix). - static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - const void *data, size_t len) { + [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE + encode_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *data, size_t len) { PROTO_ENCODE_CHECK_BOUNDS(pos, len); std::memcpy(pos, data, len); - pos += len; + return pos + len; } /// Encode tag + 1-byte length + raw string data. For strings with max_data_length < 128. /// Tag must be a single-byte varint (< 128). Always encodes (no zero check). - static inline void encode_short_string_force(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, - const StringRef &ref) { + [[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint8_t tag, const StringRef &ref) { #ifdef ESPHOME_DEBUG_API assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128"); #endif @@ -402,137 +419,191 @@ class ProtoEncode { pos[0] = tag; pos[1] = static_cast(ref.size()); std::memcpy(pos + 2, ref.c_str(), ref.size()); - pos += 2 + ref.size(); + return pos + 2 + ref.size(); } - /// Write a precomputed tag byte + 32-bit value in one operation. - static inline void ESPHOME_ALWAYS_INLINE write_tag_and_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - uint8_t tag, uint32_t value) { + /// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field. + [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) { PROTO_ENCODE_CHECK_BOUNDS(pos, 5); pos[0] = tag; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(pos + 1, &value, 4); -#else - pos[1] = static_cast(value & 0xFF); - pos[2] = static_cast((value >> 8) & 0xFF); - pos[3] = static_cast((value >> 16) & 0xFF); - pos[4] = static_cast((value >> 24) & 0xFF); -#endif - pos += 5; + write_fixed32_le(pos + 1, value); + return pos + 5; } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const char *string, size_t len, bool force = false) { - if (len == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const char *string, size_t len) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string // NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) -- false positive on [[likely]] attribute if (len < VARINT_MAX_1_BYTE) [[likely]] { PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len); *pos++ = static_cast(len); } else { - encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len); + pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len); PROTO_ENCODE_CHECK_BOUNDS(pos, len); } std::memcpy(pos, string, len); - pos += len; + return pos + len; } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const std::string &value, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size(), force); + [[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const char *string, size_t len) { + if (len == 0) + return pos; + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, string, len); } - static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const StringRef &ref, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size(), force); + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const std::string &value) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size()); } - static inline void encode_bytes(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - const uint8_t *data, size_t len, bool force = false) { - encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len, force); + [[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const StringRef &ref) { + return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size()); } - static inline void encode_uint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); - encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value); + [[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const StringRef &ref) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size()); } - static inline void encode_uint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint64_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); - encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + [[nodiscard]] static inline uint8_t *encode_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); } - static inline void encode_bool(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, bool value, - bool force = false) { - if (!value && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + [[nodiscard]] static inline uint8_t *encode_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast(data), len); + } + [[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value); + } + [[nodiscard]] static inline uint8_t *encode_uint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + if (value == 0) + return pos; + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + [[nodiscard]] static inline uint8_t *encode_uint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint64_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); + return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } + [[nodiscard]] static inline uint8_t *encode_uint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint64_t value) { + if (value == 0) + return pos; + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + [[nodiscard]] static inline uint8_t *encode_bool_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, bool value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0); PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = value ? 0x01 : 0x00; + return pos; } - static inline void encode_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - uint32_t value, bool force = false) { - if (value == 0 && !force) - return; - encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5); + [[nodiscard]] static inline uint8_t *encode_bool(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, bool value) { + if (!value) + return pos; + return encode_bool_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); + } + /// Tag + fixed32 for multi-byte tags; single-byte tags use write_tag_and_fixed32. + [[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *encode_fixed32_force( + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5); PROTO_ENCODE_CHECK_BOUNDS(pos, 4); -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(pos, &value, 4); - pos += 4; -#else - *pos++ = (value >> 0) & 0xFF; - *pos++ = (value >> 8) & 0xFF; - *pos++ = (value >> 16) & 0xFF; - *pos++ = (value >> 24) & 0xFF; -#endif + write_fixed32_le(pos, value); + return pos + 4; + } + [[nodiscard]] static inline uint8_t *encode_fixed32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, uint32_t value) { + if (value == 0) + return pos; + return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); } // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally // not supported to reduce overhead on embedded systems. All ESPHome devices are // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support // is needed in the future, the necessary encoding/decoding functions must be added. - static inline void encode_float(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, float value, - bool force = false) { - uint32_t raw = float_to_raw(value); - if (raw == 0 && !force) - return; - encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, raw); + [[nodiscard]] static inline uint8_t *encode_float(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, float value) { + return encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value)); } - static inline void encode_int32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int32_t value, - bool force = false) { + [[nodiscard]] static inline uint8_t *encode_float_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, float value) { + return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value)); + } + [[nodiscard]] static inline uint8_t *encode_int32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { if (value < 0) { // negative int32 is always 10 byte long - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); - return; + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - static inline void encode_int64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int64_t value, - bool force = false) { - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value), force); + [[nodiscard]] static inline uint8_t *encode_int32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + if (value == 0) + return pos; + return encode_int32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value); } - static inline void encode_sint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - int32_t value, bool force = false) { - encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value), force); + [[nodiscard]] static inline uint8_t *encode_int64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - static inline void encode_sint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, - int64_t value, bool force = false) { - encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value), force); + [[nodiscard]] static inline uint8_t *encode_int64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast(value)); } - /// Sub-message encoding: sync pos to buffer, delegate, get pos from return value. + [[nodiscard]] static inline uint8_t *encode_sint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + return encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int32_t value) { + return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value)); + } + [[nodiscard]] static inline uint8_t *encode_sint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, int64_t value) { + return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value)); + } + /// Sub-message encoding: sync pos to buffer, delegate, read the cursor back. template - static inline void encode_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, ProtoWriteBuffer &buffer, - uint32_t field_id, const T &value) { + [[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { buffer.set_pos(pos); buffer.encode_sub_message(field_id, value); - pos = buffer.get_pos(); + return buffer.get_pos(); } template - static inline void encode_optional_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, - ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) { + [[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + ProtoWriteBuffer &buffer, uint32_t field_id, + const T &value) { buffer.set_pos(pos); buffer.encode_optional_sub_message(field_id, value); - pos = buffer.get_pos(); + return buffer.get_pos(); + } + + private: + /// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM + /// without unaligned access), otherwise a memcpy the compiler folds into one store. Callers bounds check + /// and advance the cursor themselves. + static inline void ESPHOME_ALWAYS_INLINE write_fixed32_le(uint8_t *__restrict__ pos, uint32_t value) { + if constexpr (PROTO_FIXED32_BYTE_STORES) { + // Spelled out so the outlined helper does not itself become a memcpy call + pos[0] = static_cast(value); + pos[1] = static_cast(value >> 8); + pos[2] = static_cast(value >> 16); + pos[3] = static_cast(value >> 24); + } else { + const uint32_t le = convert_little_endian(value); + __builtin_memcpy(pos, &le, 4); + } } }; +#undef PROTO_OUTLINE_FOR_SIZE +#undef PROTO_FIXED32_BYTE_STORES #ifdef HAS_PROTO_MESSAGE_DUMP /** diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ca1c5736c89..66d47a10deb 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -126,9 +126,10 @@ def camel_to_snake(name: str) -> str: return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() -def force_str(force: bool) -> str: - """Convert a boolean force value to string format for C++ code.""" - return str(force).lower() +def _encode_call(func: str, *args: str, force: bool = False) -> str: + """Emit one ProtoEncode call; every helper takes the cursor and returns it advanced.""" + suffix = "_force" if force else "" + return f"pos = ProtoEncode::{func}{suffix}({', '.join(('pos', *args))});" class TypeInfo(ABC): @@ -264,14 +265,16 @@ class TypeInfo(ABC): # write_raw_byte(tag) + raw encode instead of the full encode_* method, # eliminating the zero-check branch and encode_field_raw indirection. # {value} is replaced with the actual field expression. - RAW_ENCODE_MAP: dict[str, str] = { - "encode_uint32": "ProtoEncode::encode_varint_raw(pos, {value});", - "encode_uint64": "ProtoEncode::encode_varint_raw_64(pos, {value});", - "encode_sint32": "ProtoEncode::encode_varint_raw_short(pos, encode_zigzag32({value}));", - "encode_sint64": "ProtoEncode::encode_varint_raw_64(pos, encode_zigzag64({value}));", - "encode_int64": "ProtoEncode::encode_varint_raw_64(pos, static_cast({value}));", - "encode_bool": "ProtoEncode::write_raw_byte(pos, {value} ? 0x01 : 0x00);", + RAW_ENCODE_MAP: dict[str, tuple[str, str]] = { + "encode_uint32": ("encode_varint_raw", "{value}"), + "encode_uint64": ("encode_varint_raw_64", "{value}"), + "encode_sint32": ("encode_varint_raw_short", "encode_zigzag32({value})"), + "encode_sint64": ("encode_varint_raw_64", "encode_zigzag64({value})"), + "encode_int64": ("encode_varint_raw_64", "static_cast({value})"), + "encode_bool": ("write_raw_byte", "{value} ? 0x01 : 0x00"), } + # Fixed32 value expression for the shared tag+fixed32 writer; None for other wire types + fixed32_value_template: str | None = None def _encode_with_precomputed_tag(self, value_expr: str) -> str | None: """Try to emit a precomputed-tag encode for a field. @@ -288,12 +291,17 @@ class TypeInfo(ABC): return None max_val = self.max_value # Only use RAW_ENCODE_MAP for forced fields or fields with max_value - raw_expr = None + raw = None if self.force or max_val is not None: - raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func) - if raw_expr is None: + raw = self.RAW_ENCODE_MAP.get(self.encode_func) + if raw is None: return None - body = f"ProtoEncode::write_raw_byte(pos, {tag});\n{raw_expr.format(value=value_expr)}" + func, arg = raw + body = ( + _encode_call("write_raw_byte", str(tag)) + + "\n" + + _encode_call(func, arg.format(value=value_expr)) + ) if self.force: return body # Non-forced with max_value: inline zero-check + raw encode @@ -314,23 +322,43 @@ class TypeInfo(ABC): return None # When max_len < 128, length varint is always 1 byte len_encode = ( - f"ProtoEncode::write_raw_byte(pos, static_cast({len_expr}));" + _encode_call("write_raw_byte", f"static_cast({len_expr})") if max_len is not None and max_len < 128 - else f"ProtoEncode::encode_varint_raw(pos, {len_expr});" + else _encode_call("encode_varint_raw", len_expr) ) + return "\n".join( + ( + _encode_call("write_raw_byte", str(tag)), + len_encode, + _encode_call("encode_raw", data_expr, len_expr), + ) + ) + + def _encode_fixed32_with_precomputed_tag(self, value_expr: str) -> str | None: + """Single-byte tag fixed32 write, or None for multi-byte tags.""" + tag = self.calculate_tag() + if tag >= 128: + return None + if self.force: + return _encode_call("write_tag_and_fixed32", str(tag), value_expr) return ( - f"ProtoEncode::write_raw_byte(pos, {tag});\n" - f"{len_encode}\n" - f"ProtoEncode::encode_raw(pos, {data_expr}, {len_expr});" + f"if (uint32_t raw = {value_expr}; raw != 0) [[likely]] {{\n" + f" {_encode_call('write_tag_and_fixed32', str(tag), 'raw')}\n" + "}" ) @property def encode_content(self) -> str: - if result := self._encode_with_precomputed_tag(f"this->{self.field_name}"): + value = f"this->{self.field_name}" + if result := self._encode_with_precomputed_tag(value): return result - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" + if self.fixed32_value_template is not None and ( + result := self._encode_fixed32_with_precomputed_tag( + self.fixed32_value_template.format(value=value) + ) + ): + return result + return _encode_call(self.encode_func, str(self.number), value, force=self.force) encode_func = None @@ -635,6 +663,8 @@ class FloatType(FixedSizeTypeMixin, TypeInfo): encode_func = "encode_float" wire_type = WireType.FIXED32 # Uses wire type 5 + fixed32_value_template = "float_to_raw({value})" + def dump(self, name: str) -> str: o = f'snprintf(buffer, sizeof(buffer), "%g", {name});\n' o += "out.append(buffer);" @@ -697,11 +727,11 @@ class UInt64Type(VarintTypeMixin, TypeInfo): return self._get_simple_size_calculation(name, force, "uint64") @property - def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 + def RAW_ENCODE_MAP(self) -> dict[str, tuple[str, str]]: # noqa: N802 if self.mac_address: return { **TypeInfo.RAW_ENCODE_MAP, - "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", + "encode_uint64": ("encode_varint_raw_48bit", "{value}"), } return TypeInfo.RAW_ENCODE_MAP @@ -769,15 +799,7 @@ class Fixed32Type(FixedSizeTypeMixin, TypeInfo): o += "out.append(buffer);" return o - @property - def encode_content(self) -> str: - tag = self.calculate_tag() - if self.force and tag < 128: - # Emit combined tag+value write: precomputed tag + direct memcpy - return f"ProtoEncode::write_tag_and_fixed32(pos, {tag}, this->{self.field_name});" - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});" + fixed32_value_template = "{value}" def get_size_calculation(self, name: str, force: bool = False) -> str: field_id_size = self.calculate_field_id_size() @@ -851,9 +873,12 @@ class StringType(TypeInfo): f"this->{self.field_name}_ref_.size()", ): return result - if self.force: - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_, true);" - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_);" + return _encode_call( + "encode_string", + str(self.number), + f"this->{self.field_name}_ref_", + force=self.force, + ) def dump(self, name): # If name is 'it', this is a repeated field element - always use string @@ -951,7 +976,9 @@ class MessageType(TypeInfo): @property def encode_content(self) -> str: # Sub-message encoding needs buffer for backpatch/sync - return f"ProtoEncode::{self.encode_func}(pos, buffer, {self.number}, this->{self.field_name});" + return _encode_call( + self.encode_func, "buffer", str(self.number), f"this->{self.field_name}" + ) @property def decode_length(self) -> str: @@ -1058,9 +1085,13 @@ class BytesType(TypeInfo): f"this->{self.field_name}_ptr_", f"this->{self.field_name}_len_" ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}_ptr_", + f"this->{self.field_name}_len_", + force=self.force, + ) def dump(self, name: str) -> str: ptr_dump = f"format_hex_pretty(this->{self.field_name}_ptr_, this->{self.field_name}_len_)" @@ -1170,9 +1201,13 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): f"this->{self.field_name}", f"this->{self.field_name}_len" ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) @property def decode_length_content(self) -> str | None: @@ -1224,16 +1259,19 @@ class PointerToStringBufferType(PointerToBufferTypeBase): if max_len is not None and max_len < 128 and self.force: tag = self.calculate_tag() if tag < 128: - return f"ProtoEncode::encode_short_string_force(pos, {tag}, this->{self.field_name});" + return _encode_call( + "encode_short_string_force", str(tag), f"this->{self.field_name}" + ) if result := self._encode_bytes_with_precomputed_tag( f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", ): return result - if self.force: - return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}, true);" - return ( - f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name});" + return _encode_call( + "encode_string", + str(self.number), + f"this->{self.field_name}", + force=self.force, ) @property @@ -1421,9 +1459,13 @@ class FixedArrayBytesType(TypeInfo): f"this->{self.field_name}", f"this->{self.field_name}_len", max_len=max_len ): return result - if self.force: - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);" - return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);" + return _encode_call( + "encode_bytes", + str(self.number), + f"this->{self.field_name}", + f"this->{self.field_name}_len", + force=self.force, + ) def dump(self, name: str) -> str: return f"out.append(format_hex_pretty({name}, {name}_len));" @@ -1520,9 +1562,9 @@ class EnumType(VarintTypeMixin, TypeInfo): @property def encode_content(self) -> str: value_expr = f"static_cast(this->{self.field_name})" - if self.force: - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr}, true);" - return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr});" + return _encode_call( + self.encode_func, str(self.number), value_expr, force=self.force + ) def dump(self, name: str) -> str: return f"out.append_p(proto_enum_to_string<{self.cpp_type}>({name}));" @@ -1701,9 +1743,9 @@ def _generate_inline_encode_block( lines = [] lines.append(f"auto &sub_msg = {element};") - lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") + lines.append(_encode_call("write_raw_byte", str(tag))) lines.append("uint8_t *len_pos = pos;") - lines.append("ProtoEncode::reserve_byte(pos);") + lines.append(_encode_call("reserve_byte")) # Generate inline field encoding for each sub-message field for field in sub_desc.field: @@ -1775,17 +1817,22 @@ class FixedArrayRepeatedType(TypeInfo): def _encode_element(self, element: str) -> str: """Helper to generate encode statement for a single element.""" if isinstance(self._ti, EnumType): - return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" + return _encode_call( + self._ti.encode_func, + str(self.number), + f"static_cast({element})", + force=True, + ) # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): if _is_inline_encode(self._ti.cpp_type): return _generate_inline_encode_block( self.number, self._ti.cpp_type, element ) - return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" - return ( - f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" - ) + return _encode_call( + "encode_sub_message", "buffer", str(self.number), element + ) + return _encode_call(self._ti.encode_func, str(self.number), element, force=True) @property def cpp_type(self) -> str: @@ -2137,13 +2184,18 @@ class RepeatedTypeInfo(TypeInfo): def _encode_element_call(self, element: str) -> str: """Helper to generate encode call for a single element.""" if isinstance(self._ti, EnumType): - return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" + return _encode_call( + self._ti.encode_func, + str(self.number), + f"static_cast({element})", + force=True, + ) # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): - return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" - return ( - f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" - ) + return _encode_call( + "encode_sub_message", "buffer", str(self.number), element + ) + return _encode_call(self._ti.encode_func, str(self.number), element, force=True) @property def encode_content(self) -> str: @@ -2152,7 +2204,7 @@ class RepeatedTypeInfo(TypeInfo): # Special handling for const char* elements (when container_no_template contains "const char") if "const char" in self._container_no_template: o = f"for (const char *it : *this->{self.field_name}) {{\n" - o += f" ProtoEncode::{self._ti.encode_func}(pos, {self.number}, it, strlen(it), true);\n" + o += f" {_encode_call(self._ti.encode_func, str(self.number), 'it', 'strlen(it)', force=True)}\n" else: o = f"for (const auto &it : *this->{self.field_name}) {{\n" o += f" {self._encode_element_call('it')}\n" diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp index 9ea6ce1cd97..46af7cd836e 100644 --- a/tests/components/api/test_proto_mac_varint.cpp +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -59,7 +59,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) { #ifdef ESPHOME_DEBUG_API uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size(); #endif - ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); + pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); size_t new_len = pos - api_buf.data(); EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec; diff --git a/tests/integration/fixtures/api_encode_boundaries.yaml b/tests/integration/fixtures/api_encode_boundaries.yaml new file mode 100644 index 00000000000..e6842d2571c --- /dev/null +++ b/tests/integration/fixtures/api_encode_boundaries.yaml @@ -0,0 +1,58 @@ +esphome: + name: api-encode-boundaries-test + # Top-level area fills DeviceInfoResponse.suggested_area (field 16, a two-byte tag) + area: + id: kitchen_area + name: Kitchen + on_boot: + - sensor.template.publish: + id: zero_then_value + state: 0.0 + +host: +api: +logger: + level: DEBUG + +sensor: + - platform: template + name: "Zero Then Value" + id: zero_then_value + # Negative int32 takes the ten byte varint path + accuracy_decimals: -2 + update_interval: never + +text_sensor: + - platform: template + name: "Long Text" + id: long_text + update_interval: never + +number: + - platform: template + name: "Negative Number" + optimistic: true + min_value: -1000 + max_value: 1000 + step: 0.5 + initial_value: -123.5 + +select: + - platform: template + name: "Long Option Select" + optimistic: true + options: + - short + - "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-when-the-list-entities-response-is-encoded-xxxxxxxxxx" + initial_option: short + +button: + - platform: template + name: "Publish Values" + on_press: + - sensor.template.publish: + id: zero_then_value + state: 12.5 + - text_sensor.template.publish: + id: long_text + state: !lambda return std::string(200, 'y'); diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 9c0debbc5cf..8b8e30c8df7 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import logging from typing import TypeVar @@ -57,6 +57,58 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) +class StateWaiter: + """Route one state subscription to any number of predicate waits.""" + + def __init__(self) -> None: + self._waiters: list[ + tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]] + ] = [] + + def on_state(self, state: EntityState) -> None: + for predicate, future in self._waiters: + if future.done(): + continue + try: + matched = predicate(state) + except Exception as exc: # noqa: BLE001 the wait re-raises it, the callback must not die + future.set_exception(exc) + continue + if matched: + future.set_result(state) + + def expect( + self, + predicate: Callable[[EntityState], bool], + timeout: float = 5.0, + label: str | None = None, + ) -> Awaitable[EntityState]: + """Arm a wait for the next state matching ``predicate`` and return the awaitable for it. + + The wait is armed here, at call time, so it can be created before the action that produces + the state and awaited afterwards; states seen before this call never match. + """ + entry = (predicate, asyncio.get_running_loop().create_future()) + self._waiters.append(entry) + return self._wait(entry, timeout, label) + + async def _wait( + self, + entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]], + timeout: float, + label: str | None, + ) -> EntityState: + try: + async with asyncio.timeout(timeout): + return await entry[1] + except TimeoutError: + raise TimeoutError( + f"no state matched {label or entry[0]} within {timeout}s" + ) from None + finally: + self._waiters.remove(entry) + + def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, diff --git a/tests/integration/test_api_encode_boundaries.py b/tests/integration/test_api_encode_boundaries.py new file mode 100644 index 00000000000..765dccbf4ef --- /dev/null +++ b/tests/integration/test_api_encode_boundaries.py @@ -0,0 +1,76 @@ +"""Encode paths at their branch boundaries: zero skipped float, fixed32 state, negative int32, +length prefixes of two varint bytes and two byte field tags.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + NumberState, + SelectInfo, + SensorInfo, + SensorState, + TextSensorState, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +LONG_OPTION = ( + "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-" + "when-the-list-entities-response-is-encoded-xxxxxxxxxx" +) + + +@pytest.mark.asyncio +async def test_api_encode_boundaries( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + device_info, (entities, _) = await asyncio.gather( + client.device_info(), client.list_entities_services() + ) + assert device_info.suggested_area == "Kitchen" + + sensor = require_entity(entities, "zero_then_value", SensorInfo) + assert sensor.accuracy_decimals == -2 + select = require_entity(entities, "long_option_select", SelectInfo) + assert len(LONG_OPTION) >= 128 + assert select.options == ["short", LONG_OPTION] + text = require_entity(entities, "long_text") + number = require_entity(entities, "negative_number") + button = require_entity(entities, "publish_values") + + initial = InitialStateHelper(entities) + waiter = StateWaiter() + client.subscribe_states(initial.on_state_wrapper(waiter.on_state)) + await initial.wait_for_initial_states() + + # A float of exactly zero is skipped on the wire and must still read as 0.0, not missing + first = initial.initial_states[sensor.key] + assert isinstance(first, SensorState) + assert first.state == 0.0 and not first.missing_state + first_number = initial.initial_states[number.key] + assert isinstance(first_number, NumberState) + assert first_number.state == -123.5 + + # Arm both waits before the press so no ordering of the replies can slip past them + sensor_seen = waiter.expect( + lambda s: ( + isinstance(s, SensorState) and s.key == sensor.key and s.state == 12.5 + ), + label="sensor 12.5", + ) + text_seen = waiter.expect( + lambda s: ( + isinstance(s, TextSensorState) + and s.key == text.key + and s.state == "y" * 200 + ), + label="text 200 x y", + ) + client.button_command(button.key) + await asyncio.gather(sensor_seen, text_seen) diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 31297911f51..a194511f2b9 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -380,3 +380,13 @@ def test_api_version_minor_is_at_least_15() -> None: "clients to see api_version >= 1.15 in HelloResponse before they will " "ever request it." ) + + +def test_generated_encode_calls_keep_the_cursor() -> None: + """No generated ProtoEncode call may drop the returned cursor.""" + dropped = [ + line + for line in CPP_TEXT.splitlines() + if "ProtoEncode::" in line and "pos = ProtoEncode::" not in line + ] + assert not dropped, dropped[:5] diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 797125ba8f9..c182520c5ac 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -15,9 +15,11 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) +import aioesphomeapi.api_options_pb2 as pb # noqa: E402 from api_protobuf import ( # noqa: E402 MAX_MESSAGE_ID, _make_ifdef_line, + create_field_type_info, get_varint64_ifdef, validate_message_id, ) @@ -43,7 +45,14 @@ UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +INT32 = descriptor_pb2.FieldDescriptorProto.TYPE_INT32 +SINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT32 FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 +FIXED32 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED32 +FLOAT = descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT +BOOL = descriptor_pb2.FieldDescriptorProto.TYPE_BOOL +STRING = descriptor_pb2.FieldDescriptorProto.TYPE_STRING +BYTES = descriptor_pb2.FieldDescriptorProto.TYPE_BYTES def test_no_varint64_fields() -> None: @@ -107,3 +116,69 @@ def test_message_id_at_maximum_is_accepted() -> None: def test_message_id_above_maximum_is_rejected() -> None: with pytest.raises(ValueError, match="exceeds the plaintext"): validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage") + + +def _field( + field_type: int, number: int = 1, *, force: bool = False, repeated: bool = False +) -> descriptor_pb2.FieldDescriptorProto: + field = descriptor_pb2.FieldDescriptorProto( + name="value", number=number, type=field_type + ) + if repeated: + field.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED + if force: + field.options.Extensions[pb.force] = True + return field + + +def _encode_field( + field_type: int, number: int = 1, force: bool = False, repeated: bool = False +) -> str: + """Return the encode statement the generator emits for one encode-only field.""" + field = _field(field_type, number, force=force, repeated=repeated) + return create_field_type_info( + field, needs_decode=False, needs_encode=True + ).encode_content + + +SCALAR_TYPES = [ + BOOL, + UINT32, + INT32, + UINT64, + INT64, + SINT32, + FLOAT, + FIXED32, + STRING, + BYTES, +] + + +@pytest.mark.parametrize("field_type", SCALAR_TYPES) +def test_forced_fields_use_the_force_overload_or_raw_writes(field_type: int) -> None: + content = _encode_field(field_type, force=True) + assert ( + "_force(" in content + or "write_raw_byte(" in content + or "write_tag_and_fixed32(" in content + ), content + + +@pytest.mark.parametrize("field_type", [FLOAT, FIXED32]) +def test_single_byte_tag_fixed32_shares_the_outlined_writer(field_type: int) -> None: + unconditional = _encode_field(field_type, force=True) + assert unconditional.count("write_tag_and_fixed32(pos, 13,") == 1, unconditional + guarded = _encode_field(field_type, force=False) + assert guarded.startswith("if ("), guarded + assert "[[likely]]" in guarded + assert "write_tag_and_fixed32(pos, 13," in guarded + + +@pytest.mark.parametrize("field_type", [FLOAT, FIXED32]) +def test_multi_byte_tag_fixed32_falls_back_to_the_generic_helper( + field_type: int, +) -> None: + content = _encode_field(field_type, number=16) + assert "write_tag_and_fixed32" not in content, content + assert content.startswith("pos = ProtoEncode::encode_"), content From b59518f6ab7fc44cbba5d69955f53bb5f8d435ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:28:29 +0100 Subject: [PATCH 435/759] [remote_transmitter] Use register_apply_action for the digital_write action (#19550) --- .../components/remote_transmitter/__init__.py | 25 +++---------------- .../remote_transmitter/automation.h | 17 ------------- .../remote_transmitter/common-buttons.yaml | 3 +++ 3 files changed, 7 insertions(+), 38 deletions(-) delete mode 100644 esphome/components/remote_transmitter/automation.h diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 58392c48abc..68eac315d51 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -20,8 +20,7 @@ from esphome.const import ( CONF_VALUE, PlatformFramework, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -38,11 +37,6 @@ remote_transmitter_ns = cg.esphome_ns.namespace("remote_transmitter") RemoteTransmitterComponent = remote_transmitter_ns.class_( "RemoteTransmitterComponent", remote_base.RemoteTransmitterBase, cg.Component ) -DigitalWriteAction = remote_transmitter_ns.class_( - "DigitalWriteAction", - automation.Action, - cg.Parented.template(RemoteTransmitterComponent), -) # Keep in sync with the USE_LIBRETINY_VARIANT_RTL8720C / REMOTE_TRANSMITTER_BK_PWM gates in @@ -138,23 +132,12 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "remote_transmitter.digital_write", - DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VALUE, "digital_write", cg.bool_), + id_key=CONF_TRANSMITTER_ID, ) -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_) - cg.add(var.set_value(template_)) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h deleted file mode 100644 index a1b0926451c..00000000000 --- a/esphome/components/remote_transmitter/automation.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/remote_transmitter/remote_transmitter.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/core/helpers.h" - -namespace esphome::remote_transmitter { - -template -class DigitalWriteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, value) - void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } -}; - -} // namespace esphome::remote_transmitter diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 981946a9a4c..1f98646d931 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -275,3 +275,6 @@ button: - remote_transmitter.digital_write: true - remote_transmitter.digital_write: value: false + - remote_transmitter.digital_write: + transmitter_id: xmitr + value: !lambda return id(remote_transmitter_test_number).state > 127; From 68235f34c550810575f444439ee18f077ca9fa61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:49:38 +0100 Subject: [PATCH 436/759] [automation] Remove dead trigger classes from switch, button, binary_sensor and event (#19563) --- esphome/components/binary_sensor/automation.h | 35 ----------------- esphome/components/button/automation.h | 7 ---- esphome/components/event/automation.h | 16 -------- esphome/components/switch/automation.h | 38 ------------------- 4 files changed, 96 deletions(-) delete mode 100644 esphome/components/event/automation.h delete mode 100644 esphome/components/switch/automation.h diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 15a1a2c8a0a..538e2316076 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,26 +18,6 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger final : public Trigger<> { - public: - explicit PressTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (state) - this->trigger(); - }); - } -}; - -class ReleaseTrigger final : public Trigger<> { - public: - explicit ReleaseTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (!state) - this->trigger(); - }); - } -}; - bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); class ClickTrigger final : public Trigger<> { @@ -140,21 +120,6 @@ template class MultiClickTrigger final : public MultiClickTriggerBase std::array timing_storage_{}; }; -class StateTrigger final : public Trigger { - public: - explicit StateTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { this->trigger(state); }); - } -}; - -class StateChangeTrigger final : public Trigger, optional > { - public: - explicit StateChangeTrigger(BinarySensor *parent) { - parent->add_full_state_callback( - [this](optional old_state, optional state) { this->trigger(old_state, state); }); - } -}; - template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index d55d43ea370..683328b394c 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -16,11 +16,4 @@ template class PressAction final : public Action { Button *button_; }; -class ButtonPressTrigger final : public Trigger<> { - public: - ButtonPressTrigger(Button *button) { - button->add_on_press_callback([this]() { this->trigger(); }); - } -}; - } // namespace esphome::button diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h deleted file mode 100644 index ee1d1a13c49..00000000000 --- a/esphome/components/event/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/components/event/event.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::event { - -class EventTrigger final : public Trigger { - public: - EventTrigger(Event *event) { - event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); - } -}; - -} // namespace esphome::event diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h deleted file mode 100644 index 8c7a3a725b8..00000000000 --- a/esphome/components/switch/automation.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/switch/switch.h" - -namespace esphome::switch_ { - -class SwitchStateTrigger final : public Trigger { - public: - SwitchStateTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); - } -}; - -class SwitchTurnOnTrigger final : public Trigger<> { - public: - SwitchTurnOnTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { - if (state) { - this->trigger(); - } - }); - } -}; - -class SwitchTurnOffTrigger final : public Trigger<> { - public: - SwitchTurnOffTrigger(Switch *a_switch) { - a_switch->add_on_state_callback([this](bool state) { - if (!state) { - this->trigger(); - } - }); - } -}; - -} // namespace esphome::switch_ From 7e308bb83df7f726f6f14f1fae712cbc1bb32c6b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 14:58:54 +0100 Subject: [PATCH 437/759] [tests] Define USE_WEBSERVER_PORT in the light unit test override (#19584) --- tests/components/light/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index 219b61dbf50..d5cdb70c9c8 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -6,6 +6,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # Enables light_json_schema.cpp without USE_MQTT, which pulls mqtt code into core/util.cpp async def to_code_testing(config): cg.add_define("USE_WEBSERVER") + # api_connection.cpp reports the port whenever USE_WEBSERVER is set + cg.add_define("USE_WEBSERVER_PORT", 80) manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] From c27cbb132dffa06ef37155d686ca059c78ca2f38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 15:13:52 +0100 Subject: [PATCH 438/759] [api] Make generated encode and size entry points type erased (#19015) --- esphome/components/api/api_connection.h | 30 +- esphome/components/api/api_pb2.cpp | 2832 +++++++++-------- esphome/components/api/api_pb2.h | 792 +++-- esphome/components/api/proto.h | 20 +- script/api_protobuf/api_protobuf.py | 26 +- .../fixtures/api_empty_message_roundtrip.yaml | 11 + .../test_api_empty_message_roundtrip.py | 37 + .../components/api/test_api_proto.py | 14 +- 8 files changed, 2206 insertions(+), 1556 deletions(-) create mode 100644 tests/integration/fixtures/api_empty_message_roundtrip.yaml create mode 100644 tests/integration/test_api_empty_message_roundtrip.py diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index c19a33ca9bb..f03c4c42e8f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -346,11 +346,7 @@ class APIConnection final : public APIServerConnectionBase { /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we /// have no silent failures: every caller must handle (or log) a refusal. template [[nodiscard]] bool send_message(const T &msg) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); - } else { - return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg, &msg); - } + return this->send_message_(T::calc_size_msg(&msg), T::MESSAGE_TYPE, &T::encode_msg, &msg); } /// Clear the shared write buffer and reserve space for the first message. @@ -406,16 +402,6 @@ class APIConnection final : public APIServerConnectionBase { void process_state_subscriptions_(); #endif - // Size thunk — converts void* back to concrete type for direct calculate_size() call - template static uint32_t calc_size(const void *msg) { - return static_cast(msg)->calculate_size(); - } - - // Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0) - static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { - return buf.get_pos(); - } - // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg); @@ -434,11 +420,7 @@ class APIConnection final : public APIServerConnectionBase { // Hot paths (state/info) go through fill_and_encode_entity_state/info instead. // batch_message_type_ is already set by dispatch_message_ before reaching here. template static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size); - } else { - return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); - } + return encode_to_buffer_slow(T::calc_size_msg(&msg), &T::encode_msg, &msg, conn, remaining_size); } // Non-template core — fills state fields and encodes @@ -450,7 +432,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_state(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_state(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills info fields, allocates buffers, and encodes @@ -462,7 +444,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_info(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_info(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills device_class, then delegates to fill_and_encode_entity_info @@ -476,8 +458,8 @@ class APIConnection final : public APIServerConnectionBase { static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg, StringRef &device_class_field, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &calc_size, - &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &T::calc_size_msg, + &T::encode_msg, conn, remaining_size); } #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c31cd3c6155..7c5cc11048c 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -31,20 +31,22 @@ bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) } return true; } -uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name); return pos; } -uint32_t HelloResponse::calculate_size() const { +uint32_t HelloResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->api_version_major); - size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += 2 + this->server_info.size(); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.api_version_major); + size += ProtoSize::calc_uint32(1, msg.api_version_minor); + size += 2 + msg.server_info.size(); + size += 2 + msg.name.size(); return size; } bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -57,388 +59,415 @@ bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t va } return true; } -uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DisconnectRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.reason)); return pos; } -uint32_t DisconnectRequest::calculate_size() const { +uint32_t DisconnectRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->reason ? 2 : 0; + size += msg.reason ? 2 : 0; return size; } #ifdef USE_AREAS -uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *AreaInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.area_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); return pos; } -uint32_t AreaInfo::calculate_size() const { +uint32_t AreaInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->area_id); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); + size += 2 + msg.name.size(); return size; } #endif #ifdef USE_DEVICES -uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id); return pos; } -uint32_t DeviceInfo::calculate_size() const { +uint32_t DeviceInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->device_id); - size += 2 + this->name.size(); - size += ProtoSize::calc_uint32(1, this->area_id); + size += ProtoSize::calc_uint32(1, msg.device_id); + size += 2 + msg.name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); return size; } #endif #ifdef USE_SERIAL_PROXY -uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyInfo::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.port_type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.configured_line_states); return pos; } -uint32_t SerialProxyInfo::calculate_size() const { +uint32_t SerialProxyInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += this->port_type ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->configured_line_states); + size += ProtoSize::calc_length(1, msg.name.size()); + size += msg.port_type ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.configured_line_states); return size; } #endif -uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); #ifdef USE_DEEP_SLEEP - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); #endif #ifdef USE_WEBSERVER - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags); #endif - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name); #ifdef USE_VOICE_ASSISTANT - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.suggested_area); #endif #ifdef USE_BLUETOOTH_PROXY - pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.bluetooth_mac_address); #endif #ifdef USE_API_NOISE - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { + for (const auto &it : msg.devices) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { + for (const auto &it : msg.areas) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); } #endif #ifdef USE_AREAS - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, msg.area); #endif #ifdef USE_ZWAVE_PROXY - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } #endif #ifdef USE_API_NOISE - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable); #endif return pos; } -uint32_t DeviceInfoResponse::calculate_size() const { +uint32_t DeviceInfoResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->name.size(); - size += 2 + this->mac_address.size(); - size += 2 + this->esphome_version.size(); - size += 2 + this->compilation_time.size(); - size += 2 + this->model.size(); + size += 2 + msg.name.size(); + size += 2 + msg.mac_address.size(); + size += 2 + msg.esphome_version.size(); + size += 2 + msg.compilation_time.size(); + size += 2 + msg.model.size(); #ifdef USE_DEEP_SLEEP - size += ProtoSize::calc_bool(1, this->has_deep_sleep); + size += ProtoSize::calc_bool(1, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_name.size(); + size += 2 + msg.project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_version.size(); + size += 2 + msg.project_version.size(); #endif #ifdef USE_WEBSERVER - size += ProtoSize::calc_uint32(1, this->webserver_port); + size += ProtoSize::calc_uint32(1, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); + size += ProtoSize::calc_uint32(1, msg.bluetooth_proxy_feature_flags); #endif - size += 2 + this->manufacturer.size(); - size += 2 + this->friendly_name.size(); + size += 2 + msg.manufacturer.size(); + size += 2 + msg.friendly_name.size(); #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); + size += ProtoSize::calc_uint32(2, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += 3 + this->suggested_area.size(); + size += 3 + msg.suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY - size += 3 + this->bluetooth_mac_address.size(); + size += 3 + msg.bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_supported); + size += ProtoSize::calc_bool(2, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { + for (const auto &it : msg.devices) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { + for (const auto &it : msg.areas) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - size += ProtoSize::calc_message(2, this->area.calculate_size()); + size += ProtoSize::calc_message(2, msg.area.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_proxy_feature_flags); + size += ProtoSize::calc_uint32(2, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_home_id); + size += ProtoSize::calc_uint32(2, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); + size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable); #endif return size; } #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.mac_address); return pos; } -uint32_t BluetoothProxyCapabilities::calculate_size() const { +uint32_t BluetoothProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += 2 + this->mac_address.size(); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += 2 + msg.mac_address.size(); return size; } #endif #ifdef USE_VOICE_ASSISTANT -uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); return pos; } -uint32_t VoiceAssistantCapabilities::calculate_size() const { +uint32_t VoiceAssistantCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } #endif #ifdef USE_ZWAVE_PROXY -uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.home_id); return pos; } -uint32_t ZWaveProxyCapabilities::calculate_size() const { +uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += ProtoSize::calc_uint32(1, this->home_id); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += ProtoSize::calc_uint32(1, msg.home_id); return size; } #endif -uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_BLUETOOTH_PROXY - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, msg.bluetooth_proxy); #endif #ifdef USE_VOICE_ASSISTANT - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, msg.voice_assistant); #endif #ifdef USE_ZWAVE_PROXY - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, msg.zwave_proxy); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } #endif return pos; } -uint32_t DeviceCapabilitiesResponse::calculate_size() const { +uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.bluetooth_proxy.calculate_size()); #endif #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); + size += ProtoSize::calc_message(1, msg.voice_assistant.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.zwave_proxy.calculate_size()); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } #endif return size; } #ifdef USE_BINARY_SENSOR -uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_class); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->is_status_binary_sensor); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.is_status_binary_sensor); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesBinarySensorResponse::calculate_size() const { +uint32_t ListEntitiesBinarySensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->is_status_binary_sensor); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.is_status_binary_sensor); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *BinarySensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BinarySensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t BinarySensorStateResponse::calculate_size() const { +uint32_t BinarySensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_COVER -uint8_t *ListEntitiesCoverResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->assumed_state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_position); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_tilt); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_tilt); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_stop); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_stop); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif return pos; } -uint32_t ListEntitiesCoverResponse::calculate_size() const { +uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_position); - size += ProtoSize::calc_bool(1, this->supports_tilt); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_position); + size += ProtoSize::calc_bool(1, msg.supports_tilt); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->supports_stop); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.supports_stop); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *CoverStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *CoverStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - if (uint32_t raw = float_to_raw(this->position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } - if (uint32_t raw = float_to_raw(this->tilt); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.tilt); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->current_operation)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(msg.current_operation)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t CoverStateResponse::calculate_size() const { +uint32_t CoverStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->position); - size += ProtoSize::calc_float(1, this->tilt); - size += this->current_operation ? 2 : 0; + size += ProtoSize::calc_float(1, msg.position); + size += ProtoSize::calc_float(1, msg.tilt); + size += msg.current_operation ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -481,75 +510,79 @@ bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_FAN -uint8_t *ListEntitiesFanResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_oscillation); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_speed); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->supports_direction); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_speed_count); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_oscillation); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_speed); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.supports_direction); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_speed_count); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->entity_category)); - for (const char *it : *this->supported_preset_modes) { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.entity_category)); + for (const char *it : *msg.supported_preset_modes) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 12, it, strlen(it)); } #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id); #endif return pos; } -uint32_t ListEntitiesFanResponse::calculate_size() const { +uint32_t ListEntitiesFanResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->supports_oscillation); - size += ProtoSize::calc_bool(1, this->supports_speed); - size += ProtoSize::calc_bool(1, this->supports_direction); - size += ProtoSize::calc_int32(1, this->supported_speed_count); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.supports_oscillation); + size += ProtoSize::calc_bool(1, msg.supports_speed); + size += ProtoSize::calc_bool(1, msg.supports_direction); + size += ProtoSize::calc_int32(1, msg.supported_speed_count); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; - if (!this->supported_preset_modes->empty()) { - for (const char *it : *this->supported_preset_modes) { + size += msg.entity_category ? 2 : 0; + if (!msg.supported_preset_modes->empty()) { + for (const char *it : *msg.supported_preset_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *FanStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *FanStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->oscillating); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(this->direction)); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->speed_level); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->preset_mode); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.oscillating); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, static_cast(msg.direction)); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.speed_level); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.preset_mode); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t FanStateResponse::calculate_size() const { +uint32_t FanStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_bool(1, this->oscillating); - size += this->direction ? 2 : 0; - size += ProtoSize::calc_int32(1, this->speed_level); - size += ProtoSize::calc_length(1, this->preset_mode.size()); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_bool(1, msg.oscillating); + size += msg.direction ? 2 : 0; + size += ProtoSize::calc_int32(1, msg.speed_level); + size += ProtoSize::calc_length(1, msg.preset_mode.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -615,113 +648,117 @@ bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_LIGHT -uint8_t *ListEntitiesLightResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - for (const auto &it : *this->supported_color_modes) { + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + for (const auto &it : *msg.supported_color_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(it)); } - if (uint32_t raw = float_to_raw(this->min_mireds); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.min_mireds); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - if (uint32_t raw = float_to_raw(this->max_mireds); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.max_mireds); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const char *it : *this->effects) { + for (const char *it : *msg.effects) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it)); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, this->disabled_by_default); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif return pos; } -uint32_t ListEntitiesLightResponse::calculate_size() const { +uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - if (!this->supported_color_modes->empty()) { - size += this->supported_color_modes->size() * 2; + size += 2 + msg.name.size(); + if (!msg.supported_color_modes->empty()) { + size += msg.supported_color_modes->size() * 2; } - size += ProtoSize::calc_float(1, this->min_mireds); - size += ProtoSize::calc_float(1, this->max_mireds); - if (!this->effects->empty()) { - for (const char *it : *this->effects) { + size += ProtoSize::calc_float(1, msg.min_mireds); + size += ProtoSize::calc_float(1, msg.max_mireds); + if (!msg.effects->empty()) { + for (const char *it : *msg.effects) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif return size; } -uint8_t *LightStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *LightStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - if (uint32_t raw = float_to_raw(this->brightness); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + if (uint32_t raw = float_to_raw(msg.brightness); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->color_mode)); - if (uint32_t raw = float_to_raw(this->color_brightness); raw != 0) [[likely]] { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.color_mode)); + if (uint32_t raw = float_to_raw(msg.color_brightness); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - if (uint32_t raw = float_to_raw(this->red); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.red); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); } - if (uint32_t raw = float_to_raw(this->green); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.green); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); } - if (uint32_t raw = float_to_raw(this->blue); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.blue); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); } - if (uint32_t raw = float_to_raw(this->white); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.white); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); } - if (uint32_t raw = float_to_raw(this->color_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.color_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - if (uint32_t raw = float_to_raw(this->cold_white); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.cold_white); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 101, raw); } - if (uint32_t raw = float_to_raw(this->warm_white); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.warm_white); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 109, raw); } - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->effect); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.effect); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t LightStateResponse::calculate_size() const { +uint32_t LightStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); - size += ProtoSize::calc_float(1, this->brightness); - size += this->color_mode ? 2 : 0; - size += ProtoSize::calc_float(1, this->color_brightness); - size += ProtoSize::calc_float(1, this->red); - size += ProtoSize::calc_float(1, this->green); - size += ProtoSize::calc_float(1, this->blue); - size += ProtoSize::calc_float(1, this->white); - size += ProtoSize::calc_float(1, this->color_temperature); - size += ProtoSize::calc_float(1, this->cold_white); - size += ProtoSize::calc_float(1, this->warm_white); - size += ProtoSize::calc_length(1, this->effect.size()); + size += ProtoSize::calc_bool(1, msg.state); + size += ProtoSize::calc_float(1, msg.brightness); + size += msg.color_mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.color_brightness); + size += ProtoSize::calc_float(1, msg.red); + size += ProtoSize::calc_float(1, msg.green); + size += ProtoSize::calc_float(1, msg.blue); + size += ProtoSize::calc_float(1, msg.white); + size += ProtoSize::calc_float(1, msg.color_temperature); + size += ProtoSize::calc_float(1, msg.cold_white); + size += ProtoSize::calc_float(1, msg.warm_white); + size += ProtoSize::calc_length(1, msg.effect.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -835,123 +872,131 @@ bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SENSOR -uint8_t *ListEntitiesSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->unit_of_measurement); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->accuracy_decimals); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->force_update); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->state_class)); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.unit_of_measurement); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.accuracy_decimals); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.force_update); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.state_class)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSensorResponse::calculate_size() const { +uint32_t ListEntitiesSensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; - size += ProtoSize::calc_int32(1, this->accuracy_decimals); - size += ProtoSize::calc_bool(1, this->force_update); - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += this->state_class ? 2 : 0; - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += !msg.unit_of_measurement.empty() ? 2 + msg.unit_of_measurement.size() : 0; + size += ProtoSize::calc_int32(1, msg.accuracy_decimals); + size += ProtoSize::calc_bool(1, msg.force_update); + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += msg.state_class ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - if (uint32_t raw = float_to_raw(this->state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SensorStateResponse::calculate_size() const { +SensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_float(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_SWITCH -uint8_t *ListEntitiesSwitchResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->assumed_state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_class); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSwitchResponse::calculate_size() const { +uint32_t ListEntitiesSwitchResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SwitchStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SwitchStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t SwitchStateResponse::calculate_size() const { +uint32_t SwitchStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); + size += ProtoSize::calc_bool(1, msg.state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -982,55 +1027,60 @@ bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_TEXT_SENSOR -uint8_t *ListEntitiesTextSensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTextSensorResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTextSensorResponse::calculate_size() const { +uint32_t ListEntitiesTextSensorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TextSensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TextSensorStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t TextSensorStateResponse::calculate_size() const { +uint32_t TextSensorStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -1050,20 +1100,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SubscribeLogsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.level)); pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); - pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); - pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.message_len_); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.message_ptr_, msg.message_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SubscribeLogsResponse::calculate_size() const { +SubscribeLogsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 2; - size += ProtoSize::calc_length_force(1, this->message_len_); + size += ProtoSize::calc_length_force(1, msg.message_len_); return size; } #ifdef USE_API_NOISE @@ -1079,81 +1131,88 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD } return true; } -uint8_t *NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } -uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const { +uint32_t NoiseEncryptionSetKeyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->success); + size += ProtoSize::calc_bool(1, msg.success); return size; } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HomeassistantServiceMap::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->value); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.value); return pos; } -uint32_t HomeassistantServiceMap::calculate_size() const { +uint32_t HomeassistantServiceMap::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->key.size()); - size += ProtoSize::calc_length(1, this->value.size()); + size += ProtoSize::calc_length(1, msg.key.size()); + size += ProtoSize::calc_length(1, msg.value.size()); return size; } -uint8_t *HomeassistantActionRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *HomeassistantActionRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->service); - for (auto &it : this->data) { + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.service); + for (auto &it : msg.data) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } - for (auto &it : this->data_template) { + for (auto &it : msg.data_template) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - for (auto &it : this->variables) { + for (auto &it : msg.variables) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->is_event); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->call_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->wants_response); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->response_template); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.response_template); #endif return pos; } -uint32_t HomeassistantActionRequest::calculate_size() const { +uint32_t HomeassistantActionRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->service.size()); - if (!this->data.empty()) { - for (const auto &it : this->data) { + size += ProtoSize::calc_length(1, msg.service.size()); + if (!msg.data.empty()) { + for (const auto &it : msg.data) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->data_template.empty()) { - for (const auto &it : this->data_template) { + if (!msg.data_template.empty()) { + for (const auto &it : msg.data_template) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->variables.empty()) { - for (const auto &it : this->variables) { + if (!msg.variables.empty()) { + for (const auto &it : msg.variables) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_bool(1, this->is_event); + size += ProtoSize::calc_bool(1, msg.is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES - size += ProtoSize::calc_uint32(1, this->call_id); + size += ProtoSize::calc_uint32(1, msg.call_id); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - size += ProtoSize::calc_bool(1, this->wants_response); + size += ProtoSize::calc_bool(1, msg.wants_response); #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - size += ProtoSize::calc_length(1, this->response_template.size()); + size += ProtoSize::calc_length(1, msg.response_template.size()); #endif return size; } @@ -1192,18 +1251,21 @@ bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDe } #endif #ifdef USE_API_HOMEASSISTANT_STATES -uint8_t *SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SubscribeHomeAssistantStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->entity_id); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->attribute); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->once); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.entity_id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.attribute); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.once); return pos; } -uint32_t SubscribeHomeAssistantStateResponse::calculate_size() const { +uint32_t SubscribeHomeAssistantStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->entity_id.size()); - size += ProtoSize::calc_length(1, this->attribute.size()); - size += ProtoSize::calc_bool(1, this->once); + size += ProtoSize::calc_length(1, msg.entity_id.size()); + size += ProtoSize::calc_length(1, msg.attribute.size()); + size += ProtoSize::calc_bool(1, msg.once); return size; } bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -1299,55 +1361,59 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { return true; } #ifdef USE_API_USER_DEFINED_ACTIONS -uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.description); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.example); #endif return pos; } -uint32_t ListEntitiesServicesArgument::calculate_size() const { +uint32_t ListEntitiesServicesArgument::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += this->type ? 2 : 0; + size += ProtoSize::calc_length(1, msg.name.size()); + size += msg.type ? 2 : 0; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->description.size()); + size += ProtoSize::calc_length(1, msg.description.size()); #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->example.size()); + size += ProtoSize::calc_length(1, msg.example.size()); #endif return size; } -uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - for (auto &it : this->args) { + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + for (auto &it : msg.args) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.supports_response)); #ifdef USE_API_USER_DEFINED_ACTION_METADATA - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.description); #endif return pos; } -uint32_t ListEntitiesServicesResponse::calculate_size() const { +uint32_t ListEntitiesServicesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); + size += ProtoSize::calc_length(1, msg.name.size()); size += 5; - if (!this->args.empty()) { - for (const auto &it : this->args) { + if (!msg.args.empty()) { + for (const auto &it : msg.args) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += this->supports_response ? 2 : 0; + size += msg.supports_response ? 2 : 0; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - size += ProtoSize::calc_length(1, this->description.size()); + size += ProtoSize::calc_length(1, msg.description.size()); #endif return size; } @@ -1456,75 +1522,81 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES -uint8_t *ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ExecuteServiceResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.call_id); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.response_data, msg.response_data_len); #endif return pos; } -uint32_t ExecuteServiceResponse::calculate_size() const { +uint32_t ExecuteServiceResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->call_id); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.call_id); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_length(1, msg.error_message.size()); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - size += ProtoSize::calc_length(1, this->response_data_len); + size += ProtoSize::calc_length(1, msg.response_data_len); #endif return size; } #endif #ifdef USE_CAMERA -uint8_t *ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesCameraResponse::calculate_size() const { +uint32_t ListEntitiesCameraResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *CameraImageResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *CameraImageResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.done); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t CameraImageResponse::calculate_size() const { +uint32_t CameraImageResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->data_len_); - size += ProtoSize::calc_bool(1, this->done); + size += ProtoSize::calc_length(1, msg.data_len_); + size += ProtoSize::calc_bool(1, msg.done); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -1543,159 +1615,163 @@ bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t v } #endif #ifdef USE_CLIMATE -uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); - for (const auto &it : *this->supported_modes) { + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_current_temperature); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_two_point_target_temperature); + for (const auto &it : *msg.supported_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it)); } - if (uint32_t raw = float_to_raw(this->visual_min_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.visual_min_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - if (uint32_t raw = float_to_raw(this->visual_max_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.visual_max_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - if (uint32_t raw = float_to_raw(this->visual_target_temperature_step); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.visual_target_temperature_step); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); - for (const auto &it : *this->supported_fan_modes) { + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_action); + for (const auto &it : *msg.supported_fan_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it)); } - for (const auto &it : *this->supported_swing_modes) { + for (const auto &it : *msg.supported_swing_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it)); } - for (const char *it : *this->supported_custom_fan_modes) { + for (const char *it : *msg.supported_custom_fan_modes) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it)); } - for (const auto &it : *this->supported_presets) { + for (const auto &it : *msg.supported_presets) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it)); } - for (const char *it : *this->supported_custom_presets) { + for (const char *it : *msg.supported_custom_presets) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it)); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.icon); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); - pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); - pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); - pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.visual_current_temperature_step); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, msg.supports_current_humidity); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.supports_target_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.visual_min_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, msg.visual_max_humidity); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.device_id); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesClimateResponse::calculate_size() const { +uint32_t ListEntitiesClimateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->supports_current_temperature); - size += ProtoSize::calc_bool(1, this->supports_two_point_target_temperature); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.supports_current_temperature); + size += ProtoSize::calc_bool(1, msg.supports_two_point_target_temperature); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_float(1, this->visual_min_temperature); - size += ProtoSize::calc_float(1, this->visual_max_temperature); - size += ProtoSize::calc_float(1, this->visual_target_temperature_step); - size += ProtoSize::calc_bool(1, this->supports_action); - if (!this->supported_fan_modes->empty()) { - size += this->supported_fan_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.visual_min_temperature); + size += ProtoSize::calc_float(1, msg.visual_max_temperature); + size += ProtoSize::calc_float(1, msg.visual_target_temperature_step); + size += ProtoSize::calc_bool(1, msg.supports_action); + if (!msg.supported_fan_modes->empty()) { + size += msg.supported_fan_modes->size() * 2; } - if (!this->supported_swing_modes->empty()) { - size += this->supported_swing_modes->size() * 2; + if (!msg.supported_swing_modes->empty()) { + size += msg.supported_swing_modes->size() * 2; } - if (!this->supported_custom_fan_modes->empty()) { - for (const char *it : *this->supported_custom_fan_modes) { + if (!msg.supported_custom_fan_modes->empty()) { + for (const char *it : *msg.supported_custom_fan_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - if (!this->supported_presets->empty()) { - size += this->supported_presets->size() * 3; + if (!msg.supported_presets->empty()) { + size += msg.supported_presets->size() * 3; } - if (!this->supported_custom_presets->empty()) { - for (const char *it : *this->supported_custom_presets) { + if (!msg.supported_custom_presets->empty()) { + for (const char *it : *msg.supported_custom_presets) { size += ProtoSize::calc_length_force(2, strlen(it)); } } - size += ProtoSize::calc_bool(2, this->disabled_by_default); + size += ProtoSize::calc_bool(2, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 3 + this->icon.size() : 0; + size += !msg.icon.empty() ? 3 + msg.icon.size() : 0; #endif - size += this->entity_category ? 3 : 0; - size += ProtoSize::calc_float(2, this->visual_current_temperature_step); - size += ProtoSize::calc_bool(2, this->supports_current_humidity); - size += ProtoSize::calc_bool(2, this->supports_target_humidity); - size += ProtoSize::calc_float(2, this->visual_min_humidity); - size += ProtoSize::calc_float(2, this->visual_max_humidity); + size += msg.entity_category ? 3 : 0; + size += ProtoSize::calc_float(2, msg.visual_current_temperature_step); + size += ProtoSize::calc_bool(2, msg.supports_current_humidity); + size += ProtoSize::calc_bool(2, msg.supports_target_humidity); + size += ProtoSize::calc_float(2, msg.visual_min_humidity); + size += ProtoSize::calc_float(2, msg.visual_max_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif - size += ProtoSize::calc_uint32(2, this->feature_flags); - size += this->temperature_unit ? 3 : 0; + size += ProtoSize::calc_uint32(2, msg.feature_flags); + size += msg.temperature_unit ? 3 : 0; return size; } -uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ClimateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - if (uint32_t raw = float_to_raw(this->current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature_low); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature_high); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); - if (uint32_t raw = float_to_raw(this->current_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.action)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.fan_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.swing_mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.custom_fan_mode); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.preset)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.custom_preset); + if (uint32_t raw = float_to_raw(msg.current_humidity); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 117, raw); } - if (uint32_t raw = float_to_raw(this->target_humidity); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_humidity); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 125, raw); } #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif return pos; } -uint32_t ClimateStateResponse::calculate_size() const { +uint32_t ClimateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->mode ? 2 : 0; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); - size += this->action ? 2 : 0; - size += this->fan_mode ? 2 : 0; - size += this->swing_mode ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_fan_mode.size()); - size += this->preset ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_preset.size()); - size += ProtoSize::calc_float(1, this->current_humidity); - size += ProtoSize::calc_float(1, this->target_humidity); + size += msg.mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += msg.action ? 2 : 0; + size += msg.fan_mode ? 2 : 0; + size += msg.swing_mode ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_fan_mode.size()); + size += msg.preset ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_preset.size()); + size += ProtoSize::calc_float(1, msg.current_humidity); + size += ProtoSize::calc_float(1, msg.target_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif return size; } @@ -1792,92 +1868,97 @@ bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_WATER_HEATER -uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - if (uint32_t raw = float_to_raw(this->min_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.min_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - if (uint32_t raw = float_to_raw(this->max_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.max_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature_step); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature_step); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const auto &it : *this->supported_modes) { + for (const auto &it : *msg.supported_modes) { pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it)); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supported_features); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { +uint32_t ListEntitiesWaterHeaterResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_float(1, this->min_temperature); - size += ProtoSize::calc_float(1, this->max_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_step); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.min_temperature); + size += ProtoSize::calc_float(1, msg.max_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_step); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_uint32(1, this->supported_features); - size += this->temperature_unit ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += msg.temperature_unit ? 2 : 0; return size; } -uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - if (uint32_t raw = float_to_raw(this->current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.mode)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); - if (uint32_t raw = float_to_raw(this->target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.state); + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); } - if (uint32_t raw = float_to_raw(this->target_temperature_high); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } return pos; } -uint32_t WaterHeaterStateResponse::calculate_size() const { +uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->state); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); + size += ProtoSize::calc_uint32(1, msg.state); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); return size; } bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -1922,73 +2003,77 @@ bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value } #endif #ifdef USE_NUMBER -uint8_t *ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - if (uint32_t raw = float_to_raw(this->min_value); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.min_value); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); } - if (uint32_t raw = float_to_raw(this->max_value); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.max_value); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); } - if (uint32_t raw = float_to_raw(this->step); raw != 0) [[likely]] { + if (uint32_t raw = float_to_raw(msg.step); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.unit_of_measurement); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_class); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t ListEntitiesNumberResponse::calculate_size() const { +uint32_t ListEntitiesNumberResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_float(1, this->min_value); - size += ProtoSize::calc_float(1, this->max_value); - size += ProtoSize::calc_float(1, this->step); - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; - size += this->mode ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_float(1, msg.min_value); + size += ProtoSize::calc_float(1, msg.max_value); + size += ProtoSize::calc_float(1, msg.step); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.unit_of_measurement.empty() ? 2 + msg.unit_of_measurement.size() : 0; + size += msg.mode ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *NumberStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *NumberStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - if (uint32_t raw = float_to_raw(this->state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t NumberStateResponse::calculate_size() const { +uint32_t NumberStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_float(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2019,61 +2104,65 @@ bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SELECT -uint8_t *ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - for (const char *it : *this->options) { + for (const char *it : *msg.options) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it)); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSelectResponse::calculate_size() const { +uint32_t ListEntitiesSelectResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - if (!this->options->empty()) { - for (const char *it : *this->options) { + if (!msg.options->empty()) { + for (const char *it : *msg.options) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SelectStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SelectStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t SelectStateResponse::calculate_size() const { +uint32_t SelectStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2112,63 +2201,67 @@ bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_SIREN -uint8_t *ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - for (const char *it : *this->tones) { + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + for (const char *it : *msg.tones) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it)); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supports_duration); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_volume); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSirenResponse::calculate_size() const { +uint32_t ListEntitiesSirenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - if (!this->tones->empty()) { - for (const char *it : *this->tones) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + if (!msg.tones->empty()) { + for (const char *it : *msg.tones) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->supports_duration); - size += ProtoSize::calc_bool(1, this->supports_volume); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.supports_duration); + size += ProtoSize::calc_bool(1, msg.supports_volume); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SirenStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SirenStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t SirenStateResponse::calculate_size() const { +uint32_t SirenStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); + size += ProtoSize::calc_bool(1, msg.state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2228,59 +2321,63 @@ bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_LOCK -uint8_t *ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_open); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.code_format); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesLockResponse::calculate_size() const { +uint32_t ListEntitiesLockResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_open); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_length(1, this->code_format.size()); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_open); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_length(1, msg.code_format.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *LockStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *LockStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t LockStateResponse::calculate_size() const { +uint32_t LockStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2325,35 +2422,37 @@ bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_BUTTON -uint8_t *ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesButtonResponse::calculate_size() const { +uint32_t ListEntitiesButtonResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2381,85 +2480,92 @@ bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_MEDIA_PLAYER -uint8_t *MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.format); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.sample_rate); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.purpose)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes); return pos; } -uint32_t MediaPlayerSupportedFormat::calculate_size() const { +uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->format.size()); - size += ProtoSize::calc_uint32(1, this->sample_rate); - size += ProtoSize::calc_uint32(1, this->num_channels); - size += this->purpose ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->sample_bytes); + size += ProtoSize::calc_length(1, msg.format.size()); + size += ProtoSize::calc_uint32(1, msg.sample_rate); + size += ProtoSize::calc_uint32(1, msg.num_channels); + size += msg.purpose ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.sample_bytes); return size; } -uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - for (auto &it : this->supported_formats) { + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + for (auto &it : msg.supported_formats) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.feature_flags); return pos; } -uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { +uint32_t ListEntitiesMediaPlayerResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - if (!this->supported_formats.empty()) { - for (const auto &it : this->supported_formats) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + if (!msg.supported_formats.empty()) { + for (const auto &it : msg.supported_formats) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } -uint8_t *MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); - if (uint32_t raw = float_to_raw(this->volume); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); + if (uint32_t raw = float_to_raw(msg.volume); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.muted); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif return pos; } -uint32_t MediaPlayerStateResponse::calculate_size() const { +uint32_t MediaPlayerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; - size += ProtoSize::calc_float(1, this->volume); - size += ProtoSize::calc_bool(1, this->muted); + size += msg.state ? 2 : 0; + size += ProtoSize::calc_float(1, msg.volume); + size += ProtoSize::calc_bool(1, msg.muted); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -2531,10 +2637,11 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); uint8_t *len_pos = pos; pos = ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); @@ -2555,10 +2662,11 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -BluetoothLERawAdvertisementsResponse::calculate_size() const { +BluetoothLERawAdvertisementsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; size += 2; size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); @@ -2588,20 +2696,23 @@ bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value } return true; } -uint8_t *BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.connected); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.mtu); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error); return pos; } -uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { +uint32_t BluetoothDeviceConnectionResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->connected); - size += ProtoSize::calc_uint32(1, this->mtu); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.connected); + size += ProtoSize::calc_uint32(1, msg.mtu); + size += ProtoSize::calc_int32(1, msg.error); return size; } bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2614,110 +2725,122 @@ bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_var } return true; } -uint8_t *BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.short_uuid); return pos; } -uint32_t BluetoothGATTDescriptor::calculate_size() const { +uint32_t BluetoothGATTDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); - for (auto &it : this->descriptors) { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.properties); + for (auto &it : msg.descriptors) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.short_uuid); return pos; } -uint32_t BluetoothGATTCharacteristic::calculate_size() const { +uint32_t BluetoothGATTCharacteristic::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->properties); - if (!this->descriptors.empty()) { - for (const auto &it : this->descriptors) { + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.properties); + if (!msg.descriptors.empty()) { + for (const auto &it : msg.descriptors) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTService::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTService::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0]); - pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - for (auto &it : this->characteristics) { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + for (auto &it : msg.characteristics) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.short_uuid); return pos; } -uint32_t BluetoothGATTService::calculate_size() const { +uint32_t BluetoothGATTService::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - if (!this->characteristics.empty()) { - for (const auto &it : this->characteristics) { + size += ProtoSize::calc_uint32(1, msg.handle); + if (!msg.characteristics.empty()) { + for (const auto &it : msg.characteristics) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTGetServicesResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - for (auto &it : this->services) { + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + for (auto &it : msg.services) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); } return pos; } -uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - if (!this->services.empty()) { - for (const auto &it : this->services) { + size += ProtoSize::calc_uint64(1, msg.address); + if (!msg.services.empty()) { + for (const auto &it : msg.services) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } return size; } -uint8_t *BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTGetServicesDoneResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); return pos; } -uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesDoneResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); + size += ProtoSize::calc_uint64(1, msg.address); return size; } bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2733,18 +2856,20 @@ bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_val } return true; } -uint8_t *BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTReadResponse::calculate_size() const { +uint32_t BluetoothGATTReadResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2829,136 +2954,160 @@ bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_v } return true; } -uint8_t *BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyDataResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -uint8_t *BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothConnectionsFreeResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); - for (const auto &it : this->allocated) { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.free); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } } return pos; } -uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { +uint32_t BluetoothConnectionsFreeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->free); - size += ProtoSize::calc_uint32(1, this->limit); - for (const auto &it : this->allocated) { + size += ProtoSize::calc_uint32(1, msg.free); + size += ProtoSize::calc_uint32(1, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { size += ProtoSize::calc_uint64_force(1, it); } } return size; } -uint8_t *BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothGATTErrorResponse::calculate_size() const { +uint32_t BluetoothGATTErrorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTWriteResponse::calculate_size() const { +uint32_t BluetoothGATTWriteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTNotifyResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDevicePairingResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.paired); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDevicePairingResponse::calculate_size() const { +uint32_t BluetoothDevicePairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->paired); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.paired); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceUnpairingResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { +uint32_t BluetoothDeviceUnpairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothDeviceClearCacheResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { +uint32_t BluetoothDeviceClearCacheResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothScannerStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.state)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.configured_mode)); return pos; } -uint32_t BluetoothScannerStateResponse::calculate_size() const { +uint32_t BluetoothScannerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->state ? 2 : 0; - size += this->mode ? 2 : 0; - size += this->configured_mode ? 2 : 0; + size += msg.state ? 2 : 0; + size += msg.mode ? 2 : 0; + size += msg.configured_mode ? 2 : 0; return size; } bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2986,38 +3135,42 @@ bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_vari } return true; } -uint8_t *VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); - if (uint32_t raw = float_to_raw(this->volume_multiplier); raw != 0) [[likely]] { + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.noise_suppression_level); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.auto_gain); + if (uint32_t raw = float_to_raw(msg.volume_multiplier); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } return pos; } -uint32_t VoiceAssistantAudioSettings::calculate_size() const { +uint32_t VoiceAssistantAudioSettings::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->noise_suppression_level); - size += ProtoSize::calc_uint32(1, this->auto_gain); - size += ProtoSize::calc_float(1, this->volume_multiplier); + size += ProtoSize::calc_uint32(1, msg.noise_suppression_level); + size += ProtoSize::calc_uint32(1, msg.auto_gain); + size += ProtoSize::calc_float(1, msg.volume_multiplier); return size; } -uint8_t *VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); - pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.start); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.conversation_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, msg.audio_settings); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); return pos; } -uint32_t VoiceAssistantRequest::calculate_size() const { +uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->start); - size += ProtoSize::calc_length(1, this->conversation_id.size()); - size += ProtoSize::calc_uint32(1, this->flags); - size += ProtoSize::calc_message(1, this->audio_settings.calculate_size()); - size += ProtoSize::calc_length(1, this->wake_word_phrase.size()); + size += ProtoSize::calc_bool(1, msg.start); + size += ProtoSize::calc_length(1, msg.conversation_id.size()); + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_message(1, msg.audio_settings.calculate_size()); + size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); return size; } bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -3096,18 +3249,20 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited } return true; } -uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAudio::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.end); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data2, msg.data2_len); return pos; } -uint32_t VoiceAssistantAudio::calculate_size() const { +uint32_t VoiceAssistantAudio::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); - size += ProtoSize::calc_bool(1, this->end); - size += ProtoSize::calc_length(1, this->data2_len); + size += ProtoSize::calc_length(1, msg.data_len); + size += ProtoSize::calc_bool(1, msg.end); + size += ProtoSize::calc_length(1, msg.data2_len); return size; } bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -3173,31 +3328,36 @@ bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLength } return true; } -uint8_t *VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } -uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { +uint32_t VoiceAssistantAnnounceFinished::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->success); + size += ProtoSize::calc_bool(1, msg.success); return size; } -uint8_t *VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); - for (auto &it : this->trained_languages) { + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.wake_word); + for (auto &it : msg.trained_languages) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } -uint32_t VoiceAssistantWakeWord::calculate_size() const { +uint32_t VoiceAssistantWakeWord::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->id.size()); - size += ProtoSize::calc_length(1, this->wake_word.size()); - if (!this->trained_languages.empty()) { - for (const auto &it : this->trained_languages) { + size += ProtoSize::calc_length(1, msg.id.size()); + size += ProtoSize::calc_length(1, msg.wake_word.size()); + if (!msg.trained_languages.empty()) { + for (const auto &it : msg.trained_languages) { size += ProtoSize::calc_length_force(1, it.size()); } } @@ -3254,30 +3414,33 @@ bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoL } return true; } -uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - for (auto &it : this->available_wake_words) { + for (auto &it : msg.available_wake_words) { pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); } - for (const auto &it : *this->active_wake_words) { + for (const auto &it : *msg.active_wake_words) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.max_active_wake_words); return pos; } -uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { +uint32_t VoiceAssistantConfigurationResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (!this->available_wake_words.empty()) { - for (const auto &it : this->available_wake_words) { + if (!msg.available_wake_words.empty()) { + for (const auto &it : msg.available_wake_words) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->active_wake_words->empty()) { - for (const auto &it : *this->active_wake_words) { + if (!msg.active_wake_words->empty()) { + for (const auto &it : *msg.active_wake_words) { size += ProtoSize::calc_length_force(1, it.size()); } } - size += ProtoSize::calc_uint32(1, this->max_active_wake_words); + size += ProtoSize::calc_uint32(1, msg.max_active_wake_words); return size; } bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { @@ -3292,57 +3455,63 @@ bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengt } #endif #ifdef USE_ALARM_CONTROL_PANEL -uint8_t *ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_features); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.requires_code); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code_to_arm); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { +uint32_t ListEntitiesAlarmControlPanelResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->supported_features); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_bool(1, this->requires_code_to_arm); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_bool(1, msg.requires_code_to_arm); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *AlarmControlPanelStateResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t AlarmControlPanelStateResponse::calculate_size() const { +uint32_t AlarmControlPanelStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3384,61 +3553,65 @@ bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit } #endif #ifdef USE_TEXT -uint8_t *ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.min_length); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.max_length); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.pattern); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.mode)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTextResponse::calculate_size() const { +uint32_t ListEntitiesTextResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->min_length); - size += ProtoSize::calc_uint32(1, this->max_length); - size += ProtoSize::calc_length(1, this->pattern.size()); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.min_length); + size += ProtoSize::calc_uint32(1, msg.max_length); + size += ProtoSize::calc_length(1, msg.pattern.size()); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TextStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TextStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t TextStateResponse::calculate_size() const { +uint32_t TextStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3477,57 +3650,61 @@ bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_DATE -uint8_t *ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateResponse::calculate_size() const { +uint32_t ListEntitiesDateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.year); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.month); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.day); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t DateStateResponse::calculate_size() const { +uint32_t DateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->year); - size += ProtoSize::calc_uint32(1, this->month); - size += ProtoSize::calc_uint32(1, this->day); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.year); + size += ProtoSize::calc_uint32(1, msg.month); + size += ProtoSize::calc_uint32(1, msg.day); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3564,57 +3741,61 @@ bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_TIME -uint8_t *ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTimeResponse::calculate_size() const { +uint32_t ListEntitiesTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *TimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.hour); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.minute); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.second); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t TimeStateResponse::calculate_size() const { +uint32_t TimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->hour); - size += ProtoSize::calc_uint32(1, this->minute); - size += ProtoSize::calc_uint32(1, this->second); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.hour); + size += ProtoSize::calc_uint32(1, msg.minute); + size += ProtoSize::calc_uint32(1, msg.second); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3651,123 +3832,131 @@ bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_EVENT -uint8_t *ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - for (const char *it : *this->event_types) { + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + for (const char *it : *msg.event_types) { pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it)); } #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesEventResponse::calculate_size() const { +uint32_t ListEntitiesEventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - if (!this->event_types->empty()) { - for (const char *it : *this->event_types) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + if (!msg.event_types->empty()) { + for (const char *it : *msg.event_types) { size += ProtoSize::calc_length_force(1, strlen(it)); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *EventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *EventResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.event_type); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t EventResponse::calculate_size() const { +uint32_t EventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->event_type.size()); + size += ProtoSize::calc_length(1, msg.event_type.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_VALVE -uint8_t *ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supports_stop); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesValveResponse::calculate_size() const { +uint32_t ListEntitiesValveResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_position); - size += ProtoSize::calc_bool(1, this->supports_stop); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_position); + size += ProtoSize::calc_bool(1, msg.supports_stop); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *ValveStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ValveStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - if (uint32_t raw = float_to_raw(this->position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); } - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.current_operation)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t ValveStateResponse::calculate_size() const { +uint32_t ValveStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->position); - size += this->current_operation ? 2 : 0; + size += ProtoSize::calc_float(1, msg.position); + size += msg.current_operation ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3804,55 +3993,59 @@ bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_DATETIME_DATETIME -uint8_t *ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateTimeResponse::calculate_size() const { +uint32_t ListEntitiesDateTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateTimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *DateTimeStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - if (uint32_t raw = this->epoch_seconds; raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + if (uint32_t raw = msg.epoch_seconds; raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); } #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t DateTimeStateResponse::calculate_size() const { +uint32_t DateTimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_fixed32(1, this->epoch_seconds); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_fixed32(1, msg.epoch_seconds); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3883,71 +4076,75 @@ bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } #endif #ifdef USE_UPDATE -uint8_t *ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesUpdateResponse::calculate_size() const { +uint32_t ListEntitiesUpdateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *UpdateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *UpdateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); - if (uint32_t raw = float_to_raw(this->progress); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.in_progress); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.has_progress); + if (uint32_t raw = float_to_raw(msg.progress); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); } - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.current_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.latest_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.title); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.release_summary); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.release_url); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t UpdateStateResponse::calculate_size() const { +uint32_t UpdateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_bool(1, this->in_progress); - size += ProtoSize::calc_bool(1, this->has_progress); - size += ProtoSize::calc_float(1, this->progress); - size += ProtoSize::calc_length(1, this->current_version.size()); - size += ProtoSize::calc_length(1, this->latest_version.size()); - size += ProtoSize::calc_length(1, this->title.size()); - size += ProtoSize::calc_length(1, this->release_summary.size()); - size += ProtoSize::calc_length(1, this->release_url.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_bool(1, msg.in_progress); + size += ProtoSize::calc_bool(1, msg.has_progress); + size += ProtoSize::calc_float(1, msg.progress); + size += ProtoSize::calc_length(1, msg.current_version.size()); + size += ProtoSize::calc_length(1, msg.latest_version.size()); + size += ProtoSize::calc_length(1, msg.title.size()); + size += ProtoSize::calc_length(1, msg.release_summary.size()); + size += ProtoSize::calc_length(1, msg.release_url.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } @@ -3992,16 +4189,18 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +ZWaveProxyFrame::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -ZWaveProxyFrame::calculate_size() const { +ZWaveProxyFrame::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); + size += ProtoSize::calc_length(1, msg.data_len); return size; } bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4026,64 +4225,70 @@ bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited va } return true; } -uint8_t *ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data, msg.data_len); return pos; } -uint32_t ZWaveProxyRequest::calculate_size() const { +uint32_t ZWaveProxyRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += ProtoSize::calc_length(1, this->data_len); + size += msg.type ? 2 : 0; + size += ProtoSize::calc_length(1, msg.data_len); return size; } -uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.status)); return pos; } -uint32_t ZWaveProxyRequestResponse::calculate_size() const { +uint32_t ZWaveProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; return size; } #endif #ifdef USE_INFRARED -uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.receiver_frequency); return pos; } -uint32_t ListEntitiesInfraredResponse::calculate_size() const { +uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->receiver_frequency); + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.receiver_frequency); return size; } #endif @@ -4134,27 +4339,29 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3 } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +InfraredRFReceiveEvent::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - for (const auto &it : *this->timings) { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + for (const auto &it : *msg.timings) { pos = ProtoEncode::encode_sint32_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -InfraredRFReceiveEvent::calculate_size() const { +InfraredRFReceiveEvent::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif size += 5; - if (!this->timings->empty()) { - for (const auto &it : *this->timings) { + if (!msg.timings->empty()) { + for (const auto &it : *msg.timings) { size += ProtoSize::calc_sint32_force(1, it); } } @@ -4162,42 +4369,45 @@ InfraredRFReceiveEvent::calculate_size() const { } #endif #ifdef USE_RADIO_FREQUENCY -uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.frequency_min); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.frequency_max); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supported_modulations); return pos; } -uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { +uint32_t ListEntitiesRadioFrequencyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->frequency_min); - size += ProtoSize::calc_uint32(1, this->frequency_max); - size += ProtoSize::calc_uint32(1, this->supported_modulations); + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.frequency_min); + size += ProtoSize::calc_uint32(1, msg.frequency_max); + size += ProtoSize::calc_uint32(1, msg.supported_modulations); return size; } #endif @@ -4229,18 +4439,20 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_ } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +SerialProxyDataReceived::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SerialProxyDataReceived::calculate_size() const { +SerialProxyDataReceived::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4288,18 +4500,21 @@ bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_vari } return true; } -uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.line_states); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); return pos; } -uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { +uint32_t SerialProxyGetModemPinsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_uint32(1, this->line_states); - size += this->status ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_uint32(1, msg.line_states); + size += msg.status ? 2 : 0; return size; } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4315,20 +4530,22 @@ bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t v } return true; } -uint8_t *SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); - pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); - pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error_message); return pos; } -uint32_t SerialProxyRequestResponse::calculate_size() const { +uint32_t SerialProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; + size += ProtoSize::calc_length(1, msg.error_message.size()); return size; } bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4368,16 +4585,19 @@ bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto } return true; } -uint8_t *BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, + ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); uint8_t *__restrict__ pos = buffer.get_pos(); - pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.error); return pos; } -uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { +uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 799aaa27b55..5d8276fbb9b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -443,8 +443,12 @@ class HelloResponse final : public ProtoMessage { uint32_t api_version_minor{0}; StringRef server_info{}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -459,8 +463,12 @@ class DisconnectRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif enums::DisconnectReason reason{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -512,8 +520,12 @@ class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -527,8 +539,12 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -542,8 +558,12 @@ class SerialProxyInfo final : public ProtoMessage { StringRef name{}; enums::SerialProxyPortType port_type{}; uint32_t configured_line_states{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -613,8 +633,12 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -626,8 +650,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; StringRef mac_address{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -639,8 +667,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { class VoiceAssistantCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -653,8 +685,12 @@ class ZWaveProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; uint32_t home_id{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -681,8 +717,12 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -712,8 +752,12 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{}; bool is_status_binary_sensor{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -729,8 +773,12 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #endif bool state{false}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -751,8 +799,12 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { bool supports_tilt{false}; StringRef device_class{}; bool supports_stop{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -769,8 +821,12 @@ class CoverStateResponse final : public StateResponseProtoMessage { float position{0.0f}; float tilt{0.0f}; enums::CoverOperation current_operation{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -811,8 +867,12 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { bool supports_direction{false}; int32_t supported_speed_count{0}; const std::vector *supported_preset_modes{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -831,8 +891,12 @@ class FanStateResponse final : public StateResponseProtoMessage { enums::FanDirection direction{}; int32_t speed_level{0}; StringRef preset_mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -878,8 +942,12 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { float min_mireds{0.0f}; float max_mireds{0.0f}; const FixedVector *effects{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -905,8 +973,12 @@ class LightStateResponse final : public StateResponseProtoMessage { float cold_white{0.0f}; float warm_white{0.0f}; StringRef effect{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -969,8 +1041,12 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { bool force_update{false}; StringRef device_class{}; enums::SensorStateClass state_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -986,8 +1062,12 @@ class SensorStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1005,8 +1085,12 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { #endif bool assumed_state{false}; StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1021,8 +1105,12 @@ class SwitchStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("switch_state_response"); } #endif bool state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1055,8 +1143,12 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1072,8 +1164,12 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1111,8 +1207,12 @@ class SubscribeLogsResponse final : public ProtoMessage { this->message_ptr_ = data; this->message_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1144,8 +1244,12 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } #endif bool success{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1158,8 +1262,12 @@ class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key{}; StringRef value{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1187,8 +1295,12 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON StringRef response_template{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1231,8 +1343,12 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { StringRef entity_id{}; StringRef attribute{}; bool once{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1328,8 +1444,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage { #ifdef USE_API_USER_DEFINED_ACTION_METADATA StringRef example{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1350,8 +1470,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef USE_API_USER_DEFINED_ACTION_METADATA StringRef description{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1420,8 +1544,12 @@ class ExecuteServiceResponse final : public ProtoMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1437,8 +1565,12 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1459,8 +1591,12 @@ class CameraImageResponse final : public StateResponseProtoMessage { this->data_len_ = len; } bool done{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1511,8 +1647,12 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; enums::TemperatureUnit temperature_unit{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1539,8 +1679,12 @@ class ClimateStateResponse final : public StateResponseProtoMessage { StringRef custom_preset{}; float current_humidity{0.0f}; float target_humidity{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1598,8 +1742,12 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; enums::TemperatureUnit temperature_unit{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1619,8 +1767,12 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1663,8 +1815,12 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { StringRef unit_of_measurement{}; enums::NumberMode mode{}; StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1680,8 +1836,12 @@ class NumberStateResponse final : public StateResponseProtoMessage { #endif float state{0.0f}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1714,8 +1874,12 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } #endif const FixedVector *options{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1731,8 +1895,12 @@ class SelectStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1768,8 +1936,12 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { const FixedVector *tones{}; bool supports_duration{false}; bool supports_volume{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1784,8 +1956,12 @@ class SirenStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("siren_state_response"); } #endif bool state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1829,8 +2005,12 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { bool supports_open{false}; bool requires_code{false}; StringRef code_format{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1845,8 +2025,12 @@ class LockStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("lock_state_response"); } #endif enums::LockState state{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1882,8 +2066,12 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1914,8 +2102,12 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; uint32_t sample_bytes{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1931,8 +2123,12 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { #endif std::vector supported_formats{}; uint32_t feature_flags{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1949,8 +2145,12 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { enums::MediaPlayerState state{}; float volume{0.0f}; bool muted{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2020,8 +2220,12 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { #endif std::array advertisements{}; uint16_t advertisements_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2059,8 +2263,12 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { bool connected{false}; uint32_t mtu{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2087,8 +2295,12 @@ class BluetoothGATTDescriptor final : public ProtoMessage { std::array uuid{}; uint32_t handle{0}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2102,8 +2314,12 @@ class BluetoothGATTCharacteristic final : public ProtoMessage { uint32_t properties{0}; FixedVector descriptors{}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2116,8 +2332,12 @@ class BluetoothGATTService final : public ProtoMessage { uint32_t handle{0}; FixedVector characteristics{}; uint32_t short_uuid{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2133,8 +2353,12 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { #endif uint64_t address{0}; std::vector services{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2149,8 +2373,12 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } #endif uint64_t address{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2188,8 +2416,12 @@ class BluetoothGATTReadResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2283,8 +2515,12 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2301,8 +2537,12 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { uint32_t free{0}; uint32_t limit{0}; std::array allocated{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2319,8 +2559,12 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { uint64_t address{0}; uint32_t handle{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2336,8 +2580,12 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2353,8 +2601,12 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { #endif uint64_t address{0}; uint32_t handle{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2371,8 +2623,12 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { uint64_t address{0}; bool paired{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2389,8 +2645,12 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2407,8 +2667,12 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { uint64_t address{0}; bool success{false}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2427,8 +2691,12 @@ class BluetoothScannerStateResponse final : public ProtoMessage { enums::BluetoothScannerState state{}; enums::BluetoothScannerMode mode{}; enums::BluetoothScannerMode configured_mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2473,8 +2741,12 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { uint32_t noise_suppression_level{0}; uint32_t auto_gain{0}; float volume_multiplier{0.0f}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2493,8 +2765,12 @@ class VoiceAssistantRequest final : public ProtoMessage { uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; StringRef wake_word_phrase{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2557,8 +2833,12 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { bool end{false}; const uint8_t *data2{nullptr}; uint16_t data2_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2615,8 +2895,12 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } #endif bool success{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2628,8 +2912,12 @@ class VoiceAssistantWakeWord final : public ProtoMessage { StringRef id{}; StringRef wake_word{}; std::vector trained_languages{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2678,8 +2966,12 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { std::vector available_wake_words{}; const std::vector *active_wake_words{}; uint32_t max_active_wake_words{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2713,8 +3005,12 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess uint32_t supported_features{0}; bool requires_code{false}; bool requires_code_to_arm{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2729,8 +3025,12 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } #endif enums::AlarmControlPanelState state{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2768,8 +3068,12 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { uint32_t max_length{0}; StringRef pattern{}; enums::TextMode mode{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2785,8 +3089,12 @@ class TextStateResponse final : public StateResponseProtoMessage { #endif StringRef state{}; bool missing_state{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2819,8 +3127,12 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2838,8 +3150,12 @@ class DateStateResponse final : public StateResponseProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2873,8 +3189,12 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2892,8 +3212,12 @@ class TimeStateResponse final : public StateResponseProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2929,8 +3253,12 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #endif StringRef device_class{}; const FixedVector *event_types{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2945,8 +3273,12 @@ class EventResponse final : public StateResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("event_response"); } #endif StringRef event_type{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2966,8 +3298,12 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -2983,8 +3319,12 @@ class ValveStateResponse final : public StateResponseProtoMessage { #endif float position{0.0f}; enums::ValveOperation current_operation{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3018,8 +3358,12 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3035,8 +3379,12 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { #endif bool missing_state{false}; uint32_t epoch_seconds{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3069,8 +3417,12 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif StringRef device_class{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3093,8 +3445,12 @@ class UpdateStateResponse final : public StateResponseProtoMessage { StringRef title{}; StringRef release_summary{}; StringRef release_url{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3128,8 +3484,12 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3147,8 +3507,12 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { enums::ZWaveProxyRequestType type{}; const uint8_t *data{nullptr}; uint16_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3166,8 +3530,12 @@ class ZWaveProxyRequestResponse final : public ProtoMessage { #endif enums::ZWaveProxyRequestType type{}; enums::ZWaveProxyStatus status{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3185,8 +3553,12 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #endif uint32_t capabilities{0}; uint32_t receiver_frequency{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3233,8 +3605,12 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #endif uint32_t key{0}; const std::vector *timings{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3254,8 +3630,12 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage uint32_t frequency_min{0}; uint32_t frequency_max{0}; uint32_t supported_modulations{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3298,8 +3678,12 @@ class SerialProxyDataReceived final : public ProtoMessage { this->data_ptr_ = data; this->data_len_ = len; } - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3365,8 +3749,12 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { uint32_t instance{0}; uint32_t line_states{0}; enums::SerialProxyStatus status{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3400,8 +3788,12 @@ class SerialProxyRequestResponse final : public ProtoMessage { enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; StringRef error_message{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3454,8 +3846,12 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #endif uint64_t address{0}; int32_t error{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index fab5194edce..f2c21736747 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -695,11 +695,12 @@ class DumpBuffer { class ProtoMessage { public: - // Non-virtual defaults for messages with no fields. - // Concrete message classes hide these with their own implementations. - // All call sites use templates to preserve the concrete type, so virtual - // dispatch is not needed. This eliminates per-message vtable entries for - // encode/calculate_size, saving ~1.3 KB of flash across all message types. + // Non-virtual defaults for messages with no fields; generated classes hide all four. The + // static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk. + static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { + return buffer.get_pos(); + } + static uint32_t calc_size_msg(const void *self) { return 0; } uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); } uint32_t calculate_size() const { return 0; } #ifdef HAS_PROTO_MESSAGE_DUMP @@ -947,19 +948,14 @@ class ProtoSize { // Implementation of methods that depend on ProtoSize being fully defined -// Encode thunk — converts void* back to concrete type for direct encode() call -template uint8_t *proto_encode_msg(const void *msg, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { - return static_cast(msg)->encode(buf PROTO_ENCODE_DEBUG_ARG); -} - // Thin template wrapper; delegates to non-template core in proto.cpp. template inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) { - this->encode_sub_message(field_id, &value, &proto_encode_msg); + this->encode_sub_message(field_id, &value, &T::encode_msg); } // Thin template wrapper; delegates to non-template core. template inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) { - this->encode_optional_sub_message(field_id, value.calculate_size(), &value, &proto_encode_msg); + this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg); } // Template decode_to_message - preserves concrete type so decode() resolves statically diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 66d47a10deb..cf9a20c46da 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2836,28 +2836,36 @@ def build_message_type( ) for line in encode ] - o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {{\n" + o += f" const auto &msg = *static_cast(self);\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" - o += indent("\n".join(encode_debug)) + "\n" + o += indent("\n".join(encode_debug)).replace("this->", "msg.") + "\n" o += " return pos;\n" o += "}\n" cpp += o - prot = ( - "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;" + public_content.append( + "static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);" + ) + public_content.append( + "uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {\n" + " return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);\n" + "}" ) - public_content.append(prot) # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used # Add calculate_size method only if this message needs encoding and has fields if needs_encode and size_calc and not is_inline_only: - o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" + o = f"{speed_attr}uint32_t {desc.name}::calc_size_msg(const void *self) {{\n" + o += f" const auto &msg = *static_cast(self);\n" o += " uint32_t size = 0;\n" - o += indent("\n".join(size_calc)) + "\n" + o += indent("\n".join(size_calc)).replace("this->", "msg.") + "\n" o += " return size;\n" o += "}\n" cpp += o - prot = "uint32_t calculate_size() const;" - public_content.append(prot) + public_content.append("static uint32_t calc_size_msg(const void *self);") + public_content.append( + "uint32_t calculate_size() const { return calc_size_msg(this); }" + ) # If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used # dump_to method declaration in header diff --git a/tests/integration/fixtures/api_empty_message_roundtrip.yaml b/tests/integration/fixtures/api_empty_message_roundtrip.yaml new file mode 100644 index 00000000000..daa4d5e60cd --- /dev/null +++ b/tests/integration/fixtures/api_empty_message_roundtrip.yaml @@ -0,0 +1,11 @@ +esphome: + name: api-empty-message-test +host: +api: +logger: + level: DEBUG + +switch: + - platform: template + name: "Empty Message Switch" + optimistic: true diff --git a/tests/integration/test_api_empty_message_roundtrip.py b/tests/integration/test_api_empty_message_roundtrip.py new file mode 100644 index 00000000000..3cd5b6cd9e4 --- /dev/null +++ b/tests/integration/test_api_empty_message_roundtrip.py @@ -0,0 +1,37 @@ +"""Messages without fields go through the shared ProtoMessage entry points on both directions.""" + +from __future__ import annotations + +from aioesphomeapi import api_pb2 +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_empty_message_roundtrip( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client: + await client.connect() + + # Field free request and reply on the plain send path + await client.send_message(api_pb2.PingRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.PingResponse]) + + # Field free request answered by a message with fields, and a list that ends with + # the field free ListEntitiesDoneResponse through the batching path + await client.send_message(api_pb2.DeviceInfoRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DeviceInfoResponse]) + await client.send_message(api_pb2.ListEntitiesRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse]) + assert ( + client.frame_counts[MESSAGE_TYPE_OF[api_pb2.ListEntitiesSwitchResponse]] + == 1 + ) + + await client.send_message(api_pb2.DisconnectRequest()) + await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DisconnectResponse]) diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index a194511f2b9..3e2f37e16c1 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -194,17 +194,17 @@ def test_superseded_device_info_fields_still_declared_in_header() -> None: def test_superseded_device_info_fields_still_encoded_and_sized() -> None: """Each superseded field must still be touched by DeviceInfoResponse's - generated encode() and calculate_size(), i.e. it is still put on the wire. + generated encode_msg() and calc_size_msg(), i.e. it is still put on the wire. """ - encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") - size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode_msg") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calc_size_msg") for field_name in SUPERSEDED_FIELDS: - assert f"this->{field_name}" in encode_body, ( - f"DeviceInfoResponse::encode() no longer references {field_name}. " + assert f"msg.{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode_msg() no longer references {field_name}. " f"{DEPRECATED_FIELD_TRAP}" ) - assert f"this->{field_name}" in size_body, ( - f"DeviceInfoResponse::calculate_size() no longer references " + assert f"msg.{field_name}" in size_body, ( + f"DeviceInfoResponse::calc_size_msg() no longer references " f"{field_name}. {DEPRECATED_FIELD_TRAP}" ) From 071fc08e326702fe76b9da2d6bb0156d977a10cb Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Thu, 24 Sep 2026 16:15:52 +0200 Subject: [PATCH 439/759] [core] Silence -Wunused-function for user builds (#19571) Co-authored-by: Claude --- esphome/core/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 8a4eb0fc379..051a136aaba 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -769,6 +769,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + cg.add_build_flag("-Wno-unused-function") # C++20 deprecated ++/--, compound assignment, and chained assignment on # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. # C++23 (P2327R1) removed the deprecation for compound assignment, so the From 7dc98c683b97c42346979f66a4f53379568e51d9 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:16:40 +0200 Subject: [PATCH 440/759] [uart] clock_source option (#16486) Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 50 ++++++++ .../uart/uart_component_esp_idf.cpp | 35 +++++- .../components/uart/uart_component_esp_idf.h | 5 +- .../component_tests/uart/test_clock_source.py | 112 ++++++++++++++++++ tests/components/uart/test.esp32-c3-idf.yaml | 1 + tests/components/uart/test.esp32-idf.yaml | 1 + tests/script/test_build_language_schema.py | 54 ++++++--- 7 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 tests/component_tests/uart/test_clock_source.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 78633bcf6a8..419659598cd 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -5,6 +5,22 @@ import re from esphome import automation, pins import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + variant_filtered_enum, +) from esphome.config_helpers import ( filter_source_files_from_defines, filter_source_files_from_platform, @@ -179,6 +195,33 @@ UART_PARITY_OPTIONS = { CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" CONF_RX_TIMEOUT = "rx_timeout" +CONF_CLOCK_SOURCE = "clock_source" + +UARTClockSource = cg.global_ns.enum("uart_sclk_t") +UART_CLOCK_SOURCES = { + "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, + "APB": UARTClockSource.UART_SCLK_APB, + "XTAL": UARTClockSource.UART_SCLK_XTAL, + "RTC": UARTClockSource.UART_SCLK_RTC, + "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, +} + +# Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. +UART_CLOCK_SOURCES_BY_VARIANT = { + VARIANT_ESP32: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32C2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32C5: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C6: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32C61: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H2: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32H21: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32P4: ["DEFAULT", "XTAL", "RTC"], + VARIANT_ESP32S2: ["DEFAULT", "APB", "REF_TICK"], + VARIANT_ESP32S3: ["DEFAULT", "APB", "XTAL", "RTC"], + VARIANT_ESP32S31: ["DEFAULT", "XTAL", "RTC"], +} UARTDirection = uart_ns.enum("UARTDirection") UART_DIRECTIONS = { @@ -263,6 +306,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLUSH_TIMEOUT): cv.All( cv.only_on_esp32, cv.positive_time_period_milliseconds ), + cv.Optional(CONF_CLOCK_SOURCE): cv.All( + cv.only_on_esp32, + variant_filtered_enum(UART_CLOCK_SOURCES_BY_VARIANT, upper=True), + ), cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), cv.Optional(CONF_PARITY, default="NONE"): cv.enum( @@ -344,6 +391,9 @@ async def to_code(config): cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) if CONF_FLUSH_TIMEOUT in config: cg.add(var.set_flush_timeout(config[CONF_FLUSH_TIMEOUT])) + # The member already defaults to UART_SCLK_DEFAULT, so only emit a real choice + if (clock_source := config.get(CONF_CLOCK_SOURCE, "DEFAULT")) != "DEFAULT": + cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) cg.add(var.set_parity(config[CONF_PARITY])) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index e5d5fbc9839..a052c6015e7 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "driver/gpio.h" #include "esp_private/gpio.h" #include "soc/gpio_num.h" +#include "soc/soc_caps.h" #include "soc/uart_pins.h" #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -37,6 +38,32 @@ static constexpr bool is_default_uart0_pin(int8_t pin_num) { return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM; } +// clock_source_ is stored in a byte; every uart_sclk_t value is a soc_module_clk_t below SOC_MOD_CLK_INVALID +static_assert(SOC_MOD_CLK_INVALID <= UINT8_MAX, "uart_sclk_t no longer fits in uint8_t clock_source_"); + +static const LogString *clock_source_to_str(uart_sclk_t clock_source) { + switch (clock_source) { +#if SOC_UART_SUPPORT_APB_CLK + case UART_SCLK_APB: + return LOG_STR("APB"); +#endif +#if SOC_UART_SUPPORT_XTAL_CLK + case UART_SCLK_XTAL: + return LOG_STR("XTAL"); +#endif +#if SOC_UART_SUPPORT_RTC_CLK + case UART_SCLK_RTC: + return LOG_STR("RTC"); +#endif +#if SOC_UART_SUPPORT_REF_TICK + case UART_SCLK_REF_TICK: + return LOG_STR("REF_TICK"); +#endif + default: + return clock_source == UART_SCLK_DEFAULT ? LOG_STR("DEFAULT") : LOG_STR("UNKNOWN"); + } +} + uart_config_t IDFUARTComponent::get_config_() { uart_parity_t parity = UART_PARITY_DISABLE; if (this->parity_ == UART_CONFIG_PARITY_EVEN) { @@ -70,7 +97,7 @@ uart_config_t IDFUARTComponent::get_config_() { uart_config.parity = parity; uart_config.stop_bits = this->stop_bits_ == 1 ? UART_STOP_BITS_1 : UART_STOP_BITS_2; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; - uart_config.source_clk = UART_SCLK_DEFAULT; + uart_config.source_clk = static_cast(this->clock_source_); uart_config.rx_flow_ctrl_thresh = 122; return uart_config; @@ -336,12 +363,14 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u" + " Stop bits: %u\n" + " Clock Source: %s" #ifdef USE_UART_WAKE_LOOP_ON_RX "\n Wake on data RX: ENABLED" #endif , - this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_, + LOG_STR_ARG(clock_source_to_str(static_cast(this->clock_source_)))); this->check_logger_conflict(); } diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index b591fbe9686..8684937b030 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -37,6 +37,8 @@ class IDFUARTComponent final : public UARTComponent, public Component { void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + void set_clock_source(uart_sclk_t clock_source) { this->clock_source_ = static_cast(clock_source); } + uint8_t get_hw_serial_number() { return this->uart_num_; } /// Discard everything received so far: the peek cache and the driver's RX buffer. @@ -105,7 +107,8 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_{0}; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback for UART RX data notification — wakes the main loop directly. diff --git a/tests/component_tests/uart/test_clock_source.py b/tests/component_tests/uart/test_clock_source.py new file mode 100644 index 00000000000..3709b593fbe --- /dev/null +++ b/tests/component_tests/uart/test_clock_source.py @@ -0,0 +1,112 @@ +"""Tests for UART clock source validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import KEY_VARIANT, VARIANTS +from esphome.components.uart import ( + CONF_CLOCK_SOURCE, + CONFIG_SCHEMA, + UART_CLOCK_SOURCES, + UART_CLOCK_SOURCES_BY_VARIANT, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def clock_source_validator() -> cv.All: + """Find the clock validator without depending on pre-validator ordering.""" + schema = next( + validator + for validator in CONFIG_SCHEMA.validators + if isinstance(validator, cv.Schema) + ) + return schema.schema[cv.Optional(CONF_CLOCK_SOURCE)] + + +def test_clock_source_table_covers_all_variants() -> None: + """New ESP32 variants must declare their supported UART clocks.""" + assert set(UART_CLOCK_SOURCES_BY_VARIANT) == set(VARIANTS) + for sources in UART_CLOCK_SOURCES_BY_VARIANT.values(): + assert "DEFAULT" in sources + assert set(sources) <= UART_CLOCK_SOURCES.keys() + + +@pytest.mark.parametrize( + ("variant", "source", "supported"), + [ + ("ESP32", "REF_TICK", True), + ("ESP32", "XTAL", False), + ("ESP32S2", "REF_TICK", True), + ("ESP32S2", "RTC", False), + ("ESP32C3", "APB", True), + ("ESP32C3", "xtal", True), + ("ESP32C3", "REF_TICK", False), + ("ESP32S3", "RTC", True), + ("ESP32S3", "REF_TICK", False), + ("ESP32C2", "APB", False), + ("ESP32C6", "APB", False), + ("ESP32C6", "DEFAULT", True), + ("ESP32C5", "RTC", True), + ("ESP32C61", "XTAL", True), + ("ESP32H4", "RTC", True), + ("ESP32H21", "XTAL", True), + ("ESP32S31", "RTC", True), + ("ESP32H2", "XTAL", True), + ("ESP32P4", "RTC", True), + ("ESP32C3", "INVALID", False), + ], +) +def test_clock_source_validation( + variant: str, + source: str, + supported: bool, + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject unsupported clocks and normalize accepted names.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + ) + if supported: + assert clock_source_validator(source) == source.upper() + else: + with pytest.raises(cv.Invalid): + clock_source_validator(source) + + +def test_clock_source_requires_esp32( + set_core_config: SetCoreConfigCallable, + clock_source_validator: cv.All, +) -> None: + """Reject the ESP32-only option before accessing the chip variant.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP32"): + clock_source_validator("DEFAULT") + + +@pytest.mark.parametrize("source", [None, "default", "xtal"]) +def test_clock_source_codegen( + source: str | None, + tmp_path: Path, + generate_main: Callable[[str | Path], str], +) -> None: + """Emit IDF constants directly and skip the setter when nothing changes.""" + config = tmp_path / "uart.yaml" + config.write_text( + "esphome:\n name: uart-clock-test\n" + "esp32:\n variant: esp32c3\n framework:\n type: esp-idf\n" + "uart:\n id: test_uart\n tx_pin: GPIO4\n baud_rate: 9600\n" + + (f" clock_source: {source}\n" if source else ""), + encoding="utf-8", + ) + main_cpp = generate_main(config) + if source in (None, "default"): + assert "set_clock_source(" not in main_cpp + else: + assert f"test_uart->set_clock_source(::UART_SCLK_{source.upper()});" in main_cpp diff --git a/tests/components/uart/test.esp32-c3-idf.yaml b/tests/components/uart/test.esp32-c3-idf.yaml index 2eae37e8245..d588203346d 100644 --- a/tests/components/uart/test.esp32-c3-idf.yaml +++ b/tests/components/uart/test.esp32-c3-idf.yaml @@ -24,5 +24,6 @@ uart: tx_pin: 18 rx_pin: 19 baud_rate: 115200 + clock_source: XTAL debug: debug_prefix: "[UART1] " diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index c8051880054..333576e1c83 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -28,6 +28,7 @@ uart: tx_pin: 21 rx_pin: 22 baud_rate: 115200 + clock_source: REF_TICK debug: debug_prefix: "[UART1] " - id: uart_debug_custom diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index f3d4bbcba65..121a84e5aa3 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -248,6 +248,41 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: assert set(converted["schema"]["config_vars"]) == {"string"} +@pytest.fixture(scope="module") +def language_schema_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Run the full language-schema build once and return the output directory. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps these tests isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return out_dir + + +def test_uart_clock_source_preserves_variant_metadata( + language_schema_dir: Path, +) -> None: + """UART clock choices retain chip restrictions in the editor schema.""" + uart_schema = json.loads((language_schema_dir / "uart.json").read_text()) + clock_source = uart_schema["uart"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ]["clock_source"] + + assert clock_source["type"] == "enum" + assert clock_source["values"]["REF_TICK"] == {"variants": ["ESP32", "ESP32S2"]} + assert "ESP32C6" in clock_source["values"]["XTAL"]["variants"] + assert "ESP32C6" not in clock_source["values"]["APB"]["variants"] + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # @@ -261,23 +296,8 @@ def test_convert_keys_bare_callable_dotted_qualname() -> None: @pytest.fixture(scope="module") -def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: - """Run the full language-schema build once and return parsed lvgl.json. - - The build must run in a fresh interpreter: ``build_language_schema.py`` - enables schema extraction *before* importing any esphome component, and the - extraction hooks are no-ops if the components were already imported (as they - are inside the pytest session). Running it as a subprocess mirrors how CI - generates the schema and keeps this test isolated from import order. - """ - out_dir = tmp_path_factory.mktemp("language_schema") - subprocess.run( - [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], - check=True, - capture_output=True, - text=True, - ) - return json.loads((out_dir / "lvgl.json").read_text()) +def lvgl_schema(language_schema_dir: Path) -> dict: + return json.loads((language_schema_dir / "lvgl.json").read_text()) def _lvgl_config_vars(lvgl_schema: dict) -> dict: From f1f83cda078f877ba4307483f33f8f0eb4838719 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 15:30:11 +0100 Subject: [PATCH 441/759] [api] Collapse the three protobuf decode virtuals into one (#19016) --- esphome/components/api/api_pb2.cpp | 2082 +++++++---------- esphome/components/api/api_pb2.h | 167 +- esphome/components/api/proto.cpp | 117 +- esphome/components/api/proto.h | 64 +- script/api_protobuf/api_protobuf.py | 345 +-- .../components/api/bench_proto_proxy.cpp | 2 +- .../fixtures/api_decode_wire_types.yaml | 43 + tests/integration/raw_api_client.py | 9 +- .../integration/test_api_decode_wire_types.py | 142 ++ .../api/test_api_protobuf_generator.py | 110 +- 10 files changed, 1351 insertions(+), 1730 deletions(-) create mode 100644 tests/integration/fixtures/api_decode_wire_types.yaml create mode 100644 tests/integration/test_api_decode_wire_types.py diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 7c5cc11048c..5afce510746 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,29 +7,19 @@ namespace esphome::api { -bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->api_version_major = value; +void HelloRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + this->client_info = StringRef(value.data(), value.size()); break; - case 3: - this->api_version_minor = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->api_version_major = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->api_version_minor = value.as_varint(); break; - default: - return false; } - return true; -} -bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; } uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -49,15 +39,13 @@ uint32_t HelloResponse::calc_size_msg(const void *self) { size += 2 + msg.name.size(); return size; } -bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->reason = static_cast(value); +void DisconnectRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->reason = static_cast(value.as_varint()); break; - default: - return false; } - return true; } uint8_t *DisconnectRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -471,42 +459,33 @@ uint32_t CoverStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 4: - this->has_position = value != 0; - break; - case 6: - this->has_tilt = value != 0; - break; - case 8: - this->stop = value != 0; - break; -#ifdef USE_DEVICES - case 9: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool CoverCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void CoverCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 5: + case proto_tag(4, WIRE_TYPE_VARINT): + this->has_position = value.as_bool(); + break; + case proto_tag(5, WIRE_TYPE_FIXED32): this->position = value.as_float(); break; - case 7: + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_tilt = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): this->tilt = value.as_float(); break; - default: - return false; + case proto_tag(8, WIRE_TYPE_VARINT): + this->stop = value.as_bool(); + break; +#ifdef USE_DEVICES + case proto_tag(9, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_FAN @@ -586,65 +565,48 @@ uint32_t FanStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 6: - this->has_oscillating = value != 0; - break; - case 7: - this->oscillating = value != 0; - break; - case 8: - this->has_direction = value != 0; - break; - case 9: - this->direction = static_cast(value); - break; - case 10: - this->has_speed_level = value != 0; - break; - case 11: - this->speed_level = static_cast(value); - break; - case 12: - this->has_preset_mode = value != 0; - break; -#ifdef USE_DEVICES - case 14: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 13: { - this->preset_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool FanCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void FanCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_state = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->state = value.as_bool(); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_oscillating = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + this->oscillating = value.as_bool(); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + this->has_direction = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_VARINT): + this->direction = static_cast(value.as_varint()); + break; + case proto_tag(10, WIRE_TYPE_VARINT): + this->has_speed_level = value.as_bool(); + break; + case proto_tag(11, WIRE_TYPE_VARINT): + this->speed_level = static_cast(value.as_varint()); + break; + case proto_tag(12, WIRE_TYPE_VARINT): + this->has_preset_mode = value.as_bool(); + break; + case proto_tag(13, WIRE_TYPE_LENGTH_DELIMITED): + this->preset_mode = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(14, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_LIGHT @@ -762,113 +724,96 @@ uint32_t LightStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_brightness = value != 0; - break; - case 22: - this->has_color_mode = value != 0; - break; - case 23: - this->color_mode = static_cast(value); - break; - case 20: - this->has_color_brightness = value != 0; - break; - case 6: - this->has_rgb = value != 0; - break; - case 10: - this->has_white = value != 0; - break; - case 12: - this->has_color_temperature = value != 0; - break; - case 24: - this->has_cold_white = value != 0; - break; - case 26: - this->has_warm_white = value != 0; - break; - case 14: - this->has_transition_length = value != 0; - break; - case 15: - this->transition_length = value; - break; - case 16: - this->has_flash_length = value != 0; - break; - case 17: - this->flash_length = value; - break; - case 18: - this->has_effect = value != 0; - break; -#ifdef USE_DEVICES - case 28: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 19: { - this->effect = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool LightCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void LightCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 5: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_state = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->state = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->has_brightness = value.as_bool(); + break; + case proto_tag(5, WIRE_TYPE_FIXED32): this->brightness = value.as_float(); break; - case 21: + case proto_tag(22, WIRE_TYPE_VARINT): + this->has_color_mode = value.as_bool(); + break; + case proto_tag(23, WIRE_TYPE_VARINT): + this->color_mode = static_cast(value.as_varint()); + break; + case proto_tag(20, WIRE_TYPE_VARINT): + this->has_color_brightness = value.as_bool(); + break; + case proto_tag(21, WIRE_TYPE_FIXED32): this->color_brightness = value.as_float(); break; - case 7: + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_rgb = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): this->red = value.as_float(); break; - case 8: + case proto_tag(8, WIRE_TYPE_FIXED32): this->green = value.as_float(); break; - case 9: + case proto_tag(9, WIRE_TYPE_FIXED32): this->blue = value.as_float(); break; - case 11: + case proto_tag(10, WIRE_TYPE_VARINT): + this->has_white = value.as_bool(); + break; + case proto_tag(11, WIRE_TYPE_FIXED32): this->white = value.as_float(); break; - case 13: + case proto_tag(12, WIRE_TYPE_VARINT): + this->has_color_temperature = value.as_bool(); + break; + case proto_tag(13, WIRE_TYPE_FIXED32): this->color_temperature = value.as_float(); break; - case 25: + case proto_tag(24, WIRE_TYPE_VARINT): + this->has_cold_white = value.as_bool(); + break; + case proto_tag(25, WIRE_TYPE_FIXED32): this->cold_white = value.as_float(); break; - case 27: + case proto_tag(26, WIRE_TYPE_VARINT): + this->has_warm_white = value.as_bool(); + break; + case proto_tag(27, WIRE_TYPE_FIXED32): this->warm_white = value.as_float(); break; - default: - return false; + case proto_tag(14, WIRE_TYPE_VARINT): + this->has_transition_length = value.as_bool(); + break; + case proto_tag(15, WIRE_TYPE_VARINT): + this->transition_length = value.as_varint(); + break; + case proto_tag(16, WIRE_TYPE_VARINT): + this->has_flash_length = value.as_bool(); + break; + case proto_tag(17, WIRE_TYPE_VARINT): + this->flash_length = value.as_varint(); + break; + case proto_tag(18, WIRE_TYPE_VARINT): + this->has_effect = value.as_bool(); + break; + case proto_tag(19, WIRE_TYPE_LENGTH_DELIMITED): + this->effect = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(28, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_SENSOR @@ -1000,30 +945,21 @@ uint32_t SwitchStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->state = value != 0; - break; -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool SwitchCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void SwitchCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->state = value.as_bool(); + break; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_TEXT_SENSOR @@ -1085,18 +1021,16 @@ uint32_t TextSensorStateResponse::calc_size_msg(const void *self) { return size; } #endif -bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->level = static_cast(value); +void SubscribeLogsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->level = static_cast(value.as_varint()); break; - case 2: - this->dump_config = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + this->dump_config = value.as_bool(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * @@ -1119,17 +1053,14 @@ SubscribeLogsResponse::calc_size_msg(const void *self) { return size; } #ifdef USE_API_NOISE -bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { +void NoiseEncryptionSetKeyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): this->key = value.data(); this->key_len = value.size(); break; - } - default: - return false; } - return true; } uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -1218,36 +1149,25 @@ uint32_t HomeassistantActionRequest::calc_size_msg(const void *self) { } #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->call_id = value; +void HomeassistantActionResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->call_id = value.as_varint(); break; - case 2: - this->success = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + this->success = value.as_bool(); break; - default: - return false; - } - return true; -} -bool HomeassistantActionResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->error_message = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + this->error_message = StringRef(value.data(), value.size()); break; - } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - case 4: { + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): this->response_data = value.data(); this->response_data_len = value.size(); break; - } #endif - default: - return false; } - return true; } #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1268,97 +1188,72 @@ uint32_t SubscribeHomeAssistantStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_bool(1, msg.once); return size; } -bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->entity_id = StringRef(reinterpret_cast(value.data()), value.size()); +void HomeAssistantStateResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + this->entity_id = StringRef(value.data(), value.size()); break; - } - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->state = StringRef(value.data(), value.size()); break; - } - case 3: { - this->attribute = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + this->attribute = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } #endif -bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->time_seconds = decode_zigzag32(static_cast(value)); +void DSTRule::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->time_seconds = decode_zigzag32(static_cast(value.as_varint())); break; - case 2: - this->day = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->day = value.as_varint(); break; - case 3: - this->type = static_cast(value); + case proto_tag(3, WIRE_TYPE_VARINT): + this->type = static_cast(value.as_varint()); break; - case 4: - this->month = value; + case proto_tag(4, WIRE_TYPE_VARINT): + this->month = value.as_varint(); break; - case 5: - this->week = value; + case proto_tag(5, WIRE_TYPE_VARINT): + this->week = value.as_varint(); break; - case 6: - this->day_of_week = value; + case proto_tag(6, WIRE_TYPE_VARINT): + this->day_of_week = value.as_varint(); break; - default: - return false; } - return true; } -bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->std_offset_seconds = decode_zigzag32(static_cast(value)); +void ParsedTimezone::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->std_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); break; - case 2: - this->dst_offset_seconds = decode_zigzag32(static_cast(value)); + case proto_tag(2, WIRE_TYPE_VARINT): + this->dst_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); break; - default: - return false; - } - return true; -} -bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): value.decode_to_message(this->dst_start); break; - case 4: + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): value.decode_to_message(this->dst_end); break; - default: - return false; } - return true; } -bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: +void GetTimeResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + this->epoch_seconds = value.as_fixed32(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): value.decode_to_message(this->parsed_timezone); this->has_parsed_timezone = true; break; - default: - return false; } - return true; -} -bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; - } - return true; } #ifdef USE_API_USER_DEFINED_ACTIONS uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -1417,54 +1312,37 @@ uint32_t ListEntitiesServicesResponse::calc_size_msg(const void *self) { #endif return size; } -bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->bool_ = value != 0; +void ExecuteServiceArgument::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->bool_ = value.as_bool(); break; - case 2: - this->legacy_int = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + this->legacy_int = static_cast(value.as_varint()); break; - case 5: - this->int_ = decode_zigzag32(static_cast(value)); - break; - case 6: - this->bool_array.push_back(value != 0); - break; - case 7: - this->int_array.push_back(decode_zigzag32(static_cast(value))); - break; - default: - return false; - } - return true; -} -bool ExecuteServiceArgument::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->string_ = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 9: - this->string_array.push_back(value.as_string()); - break; - default: - return false; - } - return true; -} -bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 3: + case proto_tag(3, WIRE_TYPE_FIXED32): this->float_ = value.as_float(); break; - case 8: + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + this->string_ = StringRef(value.data(), value.size()); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + this->int_ = decode_zigzag32(static_cast(value.as_varint())); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + this->bool_array.push_back(value.as_bool()); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + this->int_array.push_back(decode_zigzag32(static_cast(value.as_varint()))); + break; + case proto_tag(8, WIRE_TYPE_FIXED32): this->float_array.push_back(value.as_float()); break; - default: - return false; + case proto_tag(9, WIRE_TYPE_LENGTH_DELIMITED): + this->string_array.push_back(value.as_string()); + break; } - return true; } void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { uint32_t count_bool_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 6); @@ -1477,43 +1355,27 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); } -bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; +void ExecuteServiceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + this->key = value.as_fixed32(); break; -#endif -#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; - break; -#endif - default: - return false; - } - return true; -} -bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): this->args.emplace_back(); value.decode_to_message(this->args.back()); break; - default: - return false; - } - return true; -} -bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case proto_tag(3, WIRE_TYPE_VARINT): + this->call_id = value.as_varint(); break; - default: - return false; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case proto_tag(4, WIRE_TYPE_VARINT): + this->return_response = value.as_bool(); + break; +#endif } - return true; } void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2); @@ -1600,18 +1462,16 @@ uint32_t CameraImageResponse::calc_size_msg(const void *self) { #endif return size; } -bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->single = value != 0; +void CameraImageRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->single = value.as_bool(); break; - case 2: - this->stream = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + this->stream = value.as_bool(); break; - default: - return false; } - return true; } #endif #ifdef USE_CLIMATE @@ -1775,96 +1635,78 @@ uint32_t ClimateStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_mode = value != 0; - break; - case 3: - this->mode = static_cast(value); - break; - case 4: - this->has_target_temperature = value != 0; - break; - case 6: - this->has_target_temperature_low = value != 0; - break; - case 8: - this->has_target_temperature_high = value != 0; - break; - case 12: - this->has_fan_mode = value != 0; - break; - case 13: - this->fan_mode = static_cast(value); - break; - case 14: - this->has_swing_mode = value != 0; - break; - case 15: - this->swing_mode = static_cast(value); - break; - case 16: - this->has_custom_fan_mode = value != 0; - break; - case 18: - this->has_preset = value != 0; - break; - case 19: - this->preset = static_cast(value); - break; - case 20: - this->has_custom_preset = value != 0; - break; - case 22: - this->has_target_humidity = value != 0; - break; -#ifdef USE_DEVICES - case 24: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void ClimateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 5: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_mode = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->mode = static_cast(value.as_varint()); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->has_target_temperature = value.as_bool(); + break; + case proto_tag(5, WIRE_TYPE_FIXED32): this->target_temperature = value.as_float(); break; - case 7: + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_target_temperature_low = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): this->target_temperature_low = value.as_float(); break; - case 9: + case proto_tag(8, WIRE_TYPE_VARINT): + this->has_target_temperature_high = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_FIXED32): this->target_temperature_high = value.as_float(); break; - case 23: + case proto_tag(12, WIRE_TYPE_VARINT): + this->has_fan_mode = value.as_bool(); + break; + case proto_tag(13, WIRE_TYPE_VARINT): + this->fan_mode = static_cast(value.as_varint()); + break; + case proto_tag(14, WIRE_TYPE_VARINT): + this->has_swing_mode = value.as_bool(); + break; + case proto_tag(15, WIRE_TYPE_VARINT): + this->swing_mode = static_cast(value.as_varint()); + break; + case proto_tag(16, WIRE_TYPE_VARINT): + this->has_custom_fan_mode = value.as_bool(); + break; + case proto_tag(17, WIRE_TYPE_LENGTH_DELIMITED): + this->custom_fan_mode = StringRef(value.data(), value.size()); + break; + case proto_tag(18, WIRE_TYPE_VARINT): + this->has_preset = value.as_bool(); + break; + case proto_tag(19, WIRE_TYPE_VARINT): + this->preset = static_cast(value.as_varint()); + break; + case proto_tag(20, WIRE_TYPE_VARINT): + this->has_custom_preset = value.as_bool(); + break; + case proto_tag(21, WIRE_TYPE_LENGTH_DELIMITED): + this->custom_preset = StringRef(value.data(), value.size()); + break; + case proto_tag(22, WIRE_TYPE_VARINT): + this->has_target_humidity = value.as_bool(); + break; + case proto_tag(23, WIRE_TYPE_FIXED32): this->target_humidity = value.as_float(); break; - default: - return false; +#ifdef USE_DEVICES + case proto_tag(24, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_WATER_HEATER @@ -1961,45 +1803,36 @@ uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_float(1, msg.target_temperature_high); return size; } -bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_fields = value; - break; - case 3: - this->mode = static_cast(value); - break; -#ifdef USE_DEVICES - case 5: - this->device_id = value; - break; -#endif - case 6: - this->state = value; - break; - default: - return false; - } - return true; -} -bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void WaterHeaterCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 4: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_fields = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->mode = static_cast(value.as_varint()); + break; + case proto_tag(4, WIRE_TYPE_FIXED32): this->target_temperature = value.as_float(); break; - case 7: +#ifdef USE_DEVICES + case proto_tag(5, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif + case proto_tag(6, WIRE_TYPE_VARINT): + this->state = value.as_varint(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): this->target_temperature_low = value.as_float(); break; - case 8: + case proto_tag(8, WIRE_TYPE_FIXED32): this->target_temperature_high = value.as_float(); break; - default: - return false; } - return true; } #endif #ifdef USE_NUMBER @@ -2077,30 +1910,21 @@ uint32_t NumberStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void NumberCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 2: + case proto_tag(2, WIRE_TYPE_FIXED32): this->state = value.as_float(); break; - default: - return false; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_SELECT @@ -2166,38 +1990,21 @@ uint32_t SelectStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void SelectCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->state = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_SIREN @@ -2265,59 +2072,42 @@ uint32_t SirenStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; - break; - case 3: - this->state = value != 0; - break; - case 4: - this->has_tone = value != 0; - break; - case 6: - this->has_duration = value != 0; - break; - case 7: - this->duration = value; - break; - case 8: - this->has_volume = value != 0; - break; -#ifdef USE_DEVICES - case 10: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void SirenCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 9: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_state = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->state = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->has_tone = value.as_bool(); + break; + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): + this->tone = StringRef(value.data(), value.size()); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_duration = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + this->duration = value.as_varint(); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + this->has_volume = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_FIXED32): this->volume = value.as_float(); break; - default: - return false; +#ifdef USE_DEVICES + case proto_tag(10, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_LOCK @@ -2381,44 +2171,27 @@ uint32_t LockStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); - break; - case 3: - this->has_code = value != 0; - break; -#ifdef USE_DEVICES - case 5: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void LockCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->has_code = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + this->code = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(5, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_BUTTON @@ -2456,27 +2229,18 @@ uint32_t ListEntitiesButtonResponse::calc_size_msg(const void *self) { #endif return size; } -bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_DEVICES - case 2: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void ButtonCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; +#ifdef USE_DEVICES + case proto_tag(2, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_MEDIA_PLAYER @@ -2569,71 +2333,53 @@ uint32_t MediaPlayerStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_command = value != 0; - break; - case 3: - this->command = static_cast(value); - break; - case 4: - this->has_volume = value != 0; - break; - case 6: - this->has_media_url = value != 0; - break; - case 8: - this->has_announcement = value != 0; - break; - case 9: - this->announcement = value != 0; - break; -#ifdef USE_DEVICES - case 10: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void MediaPlayerCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 5: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_command = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->command = static_cast(value.as_varint()); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->has_volume = value.as_bool(); + break; + case proto_tag(5, WIRE_TYPE_FIXED32): this->volume = value.as_float(); break; - default: - return false; + case proto_tag(6, WIRE_TYPE_VARINT): + this->has_media_url = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + this->media_url = StringRef(value.data(), value.size()); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + this->has_announcement = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_VARINT): + this->announcement = value.as_bool(); + break; +#ifdef USE_DEVICES + case proto_tag(10, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_BLUETOOTH_PROXY -bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->flags = value; +void SubscribeBluetoothLEAdvertisementsRequest::decode_field(uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->flags = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * @@ -2677,24 +2423,22 @@ BluetoothLERawAdvertisementsResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothDeviceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->request_type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + this->request_type = static_cast(value.as_varint()); break; - case 3: - this->has_address_type = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + this->has_address_type = value.as_bool(); break; - case 4: - this->address_type = value; + case proto_tag(4, WIRE_TYPE_VARINT): + this->address_type = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -2715,15 +2459,13 @@ uint32_t BluetoothDeviceConnectionResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_int32(1, msg.error); return size; } -bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTGetServicesRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -2843,18 +2585,16 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint64(1, msg.address); return size; } -bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTReadRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->handle = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -2872,87 +2612,63 @@ uint32_t BluetoothGATTReadResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->handle = value.as_varint(); break; - case 3: - this->response = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + this->response = value.as_bool(); break; - default: - return false; - } - return true; -} -bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - default: - return false; } - return true; } -bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTReadDescriptorRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->handle = value.as_varint(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteDescriptorRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->handle = value.as_varint(); break; - default: - return false; - } - return true; -} -bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - default: - return false; } - return true; } -bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTNotifyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->handle = value.as_varint(); break; - case 3: - this->enable = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + this->enable = value.as_bool(); break; - default: - return false; } - return true; } uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -3110,30 +2826,26 @@ uint32_t BluetoothScannerStateResponse::calc_size_msg(const void *self) { size += msg.configured_mode ? 2 : 0; return size; } -bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->mode = static_cast(value); +void BluetoothScannerSetModeRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; } #endif #ifdef USE_VOICE_ASSISTANT -bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->subscribe = value != 0; +void SubscribeVoiceAssistantRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->subscribe = value.as_bool(); break; - case 2: - this->flags = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->flags = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -3173,81 +2885,55 @@ uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); return size; } -bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->port = value; +void VoiceAssistantResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->port = value.as_varint(); break; - case 2: - this->error = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + this->error = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantEventData::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + this->name = StringRef(value.data(), value.size()); break; - } - case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->value = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } -bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantEventResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->event_type = static_cast(value.as_varint()); break; - default: - return false; - } - return true; -} -bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): this->data.emplace_back(); value.decode_to_message(this->data.back()); break; - default: - return false; } - return true; } -bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { +void VoiceAssistantAudio::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - case 3: { + case proto_tag(2, WIRE_TYPE_VARINT): + this->end = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): this->data2 = value.data(); this->data2_len = value.size(); break; - } - default: - return false; } - return true; } uint8_t *VoiceAssistantAudio::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -3265,68 +2951,45 @@ uint32_t VoiceAssistantAudio::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data2_len); return size; } -bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantTimerEventResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->event_type = static_cast(value.as_varint()); break; - case 4: - this->total_seconds = value; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->timer_id = StringRef(value.data(), value.size()); break; - case 5: - this->seconds_left = value; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + this->name = StringRef(value.data(), value.size()); break; - case 6: - this->is_active = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + this->total_seconds = value.as_varint(); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + this->seconds_left = value.as_varint(); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + this->is_active = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantAnnounceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + this->media_id = StringRef(value.data(), value.size()); break; - } - case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->text = StringRef(value.data(), value.size()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + this->preannounce_media_id = StringRef(value.data(), value.size()); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->start_conversation = value.as_bool(); break; - } - default: - return false; } - return true; -} -bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; } uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -3363,56 +3026,40 @@ uint32_t VoiceAssistantWakeWord::calc_size_msg(const void *self) { } return size; } -bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 5: - this->model_size = value; +void VoiceAssistantExternalWakeWord::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + this->id = StringRef(value.data(), value.size()); break; - default: - return false; - } - return true; -} -bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->wake_word = StringRef(value.data(), value.size()); break; - } - case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): this->trained_languages.push_back(value.as_string()); break; - case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + this->model_type = StringRef(value.data(), value.size()); break; - } - case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(5, WIRE_TYPE_VARINT): + this->model_size = value.as_varint(); break; - } - case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(6, WIRE_TYPE_LENGTH_DELIMITED): + this->model_hash = StringRef(value.data(), value.size()); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + this->url = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } -bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: +void VoiceAssistantConfigurationRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): this->external_wake_words.emplace_back(); value.decode_to_message(this->external_wake_words.back()); break; - default: - return false; } - return true; } uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -3443,15 +3090,13 @@ uint32_t VoiceAssistantConfigurationResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.max_active_wake_words); return size; } -bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: +void VoiceAssistantSetConfiguration::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): this->active_wake_words.push_back(value.as_string()); break; - default: - return false; } - return true; } #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -3515,41 +3160,24 @@ uint32_t AlarmControlPanelStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); - break; -#ifdef USE_DEVICES - case 4: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void AlarmControlPanelCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + this->code = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(4, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_TEXT @@ -3615,38 +3243,21 @@ uint32_t TextStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void TextCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + this->state = StringRef(value.data(), value.size()); + break; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_DATETIME_DATE @@ -3708,36 +3319,27 @@ uint32_t DateStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->year = value; - break; - case 3: - this->month = value; - break; - case 4: - this->day = value; - break; -#ifdef USE_DEVICES - case 5: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void DateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->year = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->month = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->day = value.as_varint(); + break; +#ifdef USE_DEVICES + case proto_tag(5, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_DATETIME_TIME @@ -3799,36 +3401,27 @@ uint32_t TimeStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->hour = value; - break; - case 3: - this->minute = value; - break; - case 4: - this->second = value; - break; -#ifdef USE_DEVICES - case 5: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void TimeCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->hour = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + this->minute = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + this->second = value.as_varint(); + break; +#ifdef USE_DEVICES + case proto_tag(5, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_EVENT @@ -3960,36 +3553,27 @@ uint32_t ValveStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_position = value != 0; - break; - case 4: - this->stop = value != 0; - break; -#ifdef USE_DEVICES - case 5: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void ValveCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 3: + case proto_tag(2, WIRE_TYPE_VARINT): + this->has_position = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_FIXED32): this->position = value.as_float(); break; - default: - return false; + case proto_tag(4, WIRE_TYPE_VARINT): + this->stop = value.as_bool(); + break; +#ifdef USE_DEVICES + case proto_tag(5, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_DATETIME_DATETIME @@ -4049,30 +3633,21 @@ uint32_t DateTimeStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void DateTimeCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - case 2: + case proto_tag(2, WIRE_TYPE_FIXED32): this->epoch_seconds = value.as_fixed32(); break; - default: - return false; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_UPDATE @@ -4148,44 +3723,32 @@ uint32_t UpdateStateResponse::calc_size_msg(const void *self) { #endif return size; } -bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); - break; -#ifdef USE_DEVICES - case 3: - this->device_id = value; - break; -#endif - default: - return false; - } - return true; -} -bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: +void UpdateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): this->key = value.as_fixed32(); break; - default: - return false; + case proto_tag(2, WIRE_TYPE_VARINT): + this->command = static_cast(value.as_varint()); + break; +#ifdef USE_DEVICES + case proto_tag(3, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); + break; +#endif } - return true; } #endif #ifdef USE_ZWAVE_PROXY -bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { +void ZWaveProxyFrame::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * @@ -4203,27 +3766,17 @@ ZWaveProxyFrame::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len); return size; } -bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->type = static_cast(value); +void ZWaveProxyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->type = static_cast(value.as_varint()); break; - default: - return false; - } - return true; -} -bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - default: - return false; } - return true; } uint8_t *ZWaveProxyRequest::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -4293,49 +3846,32 @@ uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { } #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void InfraredRFTransmitRawTimingsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { #ifdef USE_DEVICES - case 1: - this->device_id = value; + case proto_tag(1, WIRE_TYPE_VARINT): + this->device_id = value.as_varint(); break; #endif - case 3: - this->carrier_frequency = value; + case proto_tag(2, WIRE_TYPE_FIXED32): + this->key = value.as_fixed32(); break; - case 4: - this->repeat_count = value; + case proto_tag(3, WIRE_TYPE_VARINT): + this->carrier_frequency = value.as_varint(); break; - case 6: - this->modulation = value; + case proto_tag(4, WIRE_TYPE_VARINT): + this->repeat_count = value.as_varint(); break; - default: - return false; - } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): this->timings_data_ = value.data(); this->timings_length_ = value.size(); this->timings_count_ = count_packed_varints(value.data(), value.size()); break; - } - default: - return false; - } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 2: - this->key = value.as_fixed32(); + case proto_tag(6, WIRE_TYPE_VARINT): + this->modulation = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * @@ -4412,30 +3948,28 @@ uint32_t ListEntitiesRadioFrequencyResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_SERIAL_PROXY -bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyConfigureRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - case 2: - this->baudrate = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->baudrate = value.as_varint(); break; - case 3: - this->flow_control = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + this->flow_control = value.as_bool(); break; - case 4: - this->parity = static_cast(value); + case proto_tag(4, WIRE_TYPE_VARINT): + this->parity = static_cast(value.as_varint()); break; - case 5: - this->stop_bits = value; + case proto_tag(5, WIRE_TYPE_VARINT): + this->stop_bits = value.as_varint(); break; - case 6: - this->data_size = value; + case proto_tag(6, WIRE_TYPE_VARINT): + this->data_size = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * @@ -4455,50 +3989,36 @@ SerialProxyDataReceived::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyWriteRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - default: - return false; - } - return true; -} -bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): this->data = value.data(); this->data_len = value.size(); break; - } - default: - return false; } - return true; } -bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxySetModemPinsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - case 2: - this->line_states = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->line_states = value.as_varint(); break; - default: - return false; } - return true; } -bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyGetModemPinsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -4517,18 +4037,16 @@ uint32_t SerialProxyGetModemPinsResponse::calc_size_msg(const void *self) { size += msg.status ? 2 : 0; return size; } -bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - case 2: - this->type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + this->type = static_cast(value.as_varint()); break; - default: - return false; } - return true; } uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -4548,42 +4066,38 @@ uint32_t SerialProxyRequestResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.error_message.size()); return size; } -bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxySetModeRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->instance = value.as_varint(); break; - case 2: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + this->mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothSetConnectionParamsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + this->address = value.as_varint(); break; - case 2: - this->min_interval = value; + case proto_tag(2, WIRE_TYPE_VARINT): + this->min_interval = value.as_varint(); break; - case 3: - this->max_interval = value; + case proto_tag(3, WIRE_TYPE_VARINT): + this->max_interval = value.as_varint(); break; - case 4: - this->latency = value; + case proto_tag(4, WIRE_TYPE_VARINT): + this->latency = value.as_varint(); break; - case 5: - this->timeout = value; + case proto_tag(5, WIRE_TYPE_VARINT): + this->timeout = value.as_varint(); break; - default: - return false; } - return true; } uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5d8276fbb9b..b4c742fee98 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -429,8 +429,7 @@ class HelloRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class HelloResponse final : public ProtoMessage { public: @@ -474,7 +473,7 @@ class DisconnectRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class DisconnectResponse final : public ProtoMessage { public: @@ -850,8 +849,7 @@ class CoverCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_FAN @@ -925,9 +923,7 @@ class FanCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_LIGHT @@ -1023,9 +1019,7 @@ class LightCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_SENSOR @@ -1130,8 +1124,7 @@ class SwitchCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_TEXT_SENSOR @@ -1191,7 +1184,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1234,7 +1227,7 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: @@ -1328,8 +1321,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1370,7 +1362,7 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif class GetTimeRequest final : public ProtoMessage { @@ -1399,7 +1391,7 @@ class DSTRule final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1412,8 +1404,7 @@ class ParsedTimezone final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1430,8 +1421,7 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { @@ -1499,9 +1489,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1524,9 +1512,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1617,7 +1603,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_CLIMATE @@ -1723,9 +1709,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_WATER_HEATER @@ -1797,8 +1781,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_NUMBER @@ -1861,8 +1844,7 @@ class NumberCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_SELECT @@ -1920,9 +1902,7 @@ class SelectCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_SIREN @@ -1988,9 +1968,7 @@ class SirenCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_LOCK @@ -2052,9 +2030,7 @@ class LockCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_BUTTON @@ -2090,8 +2066,7 @@ class ButtonCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_MEDIA_PLAYER @@ -2177,9 +2152,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -2196,7 +2169,7 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -2250,7 +2223,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -2288,7 +2261,7 @@ class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTDescriptor final : public ProtoMessage { public: @@ -2399,7 +2372,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2445,8 +2418,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2462,7 +2434,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2480,8 +2452,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2498,7 +2469,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2716,7 +2687,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2734,7 +2705,7 @@ class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantAudioSettings final : public ProtoMessage { public: @@ -2791,7 +2762,7 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: @@ -2802,7 +2773,7 @@ class VoiceAssistantEventData final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: @@ -2818,8 +2789,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2844,8 +2814,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: @@ -2865,8 +2834,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2884,8 +2852,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2938,8 +2905,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2954,7 +2920,7 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: @@ -2991,7 +2957,7 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -3051,9 +3017,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_TEXT @@ -3114,9 +3078,7 @@ class TextCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_DATETIME_DATE @@ -3177,8 +3139,7 @@ class DateCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_DATETIME_TIME @@ -3239,8 +3200,7 @@ class TimeCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_EVENT @@ -3346,8 +3306,7 @@ class ValveCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_DATETIME_DATETIME @@ -3404,8 +3363,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_UPDATE @@ -3470,8 +3428,7 @@ class UpdateCommandRequest final : public CommandProtoMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_ZWAVE_PROXY @@ -3495,7 +3452,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class ZWaveProxyRequest final : public ProtoDecodableMessage { public: @@ -3518,8 +3475,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class ZWaveProxyRequestResponse final : public ProtoMessage { public: @@ -3589,9 +3545,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3662,7 +3616,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3705,8 +3659,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3722,7 +3675,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3737,7 +3690,7 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3775,7 +3728,7 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class SerialProxyRequestResponse final : public ProtoMessage { public: @@ -3814,7 +3767,7 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS @@ -3835,7 +3788,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 61ac5f77015..6b558f7f923 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -220,73 +220,74 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { const uint8_t *ptr = buffer; const uint8_t *end = buffer + length; - while (ptr < end) { - // Parse field header - ptr < end guarantees len >= 1 + // Single-byte varints dominate, so that case advances the cursor inline. + auto read_varint = [&](proto_varint_value_t &value) ESPHOME_ALWAYS_INLINE { + if (ptr == end) + return false; + if (*ptr < 0x80) [[likely]] { + value = *ptr++; + return true; + } auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); - if (!res.has_value()) { + if (!res.has_value()) + return false; + value = res.value; + ptr += res.consumed; + return true; + }; + + while (ptr < end) { + proto_varint_value_t tag_value; + if (!read_varint(tag_value)) { ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); return; } - uint32_t tag = static_cast(res.value); + uint32_t tag = static_cast(tag_value); uint32_t field_type = tag & WIRE_TYPE_MASK; - uint32_t field_id = tag >> 3; - ptr += res.consumed; + // Length-delimited fields move this past the length prefix + const uint8_t *data = ptr; + proto_varint_value_t scalar; - switch (field_type) { - case WIRE_TYPE_VARINT: { // VarInt - res = ProtoVarInt::parse(ptr, end - ptr); - if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); - return; - } - if (!this->decode_varint(field_id, res.value)) { - ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu64 "!", field_id, - static_cast(res.value)); - } - ptr += res.consumed; - break; - } - case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - res = ProtoVarInt::parse(ptr, end - ptr); - if (!res.has_value()) { - ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); - return; - } - uint32_t field_length = static_cast(res.value); - ptr += res.consumed; - if (field_length > static_cast(end - ptr)) { - ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); - return; - } - if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) { - ESP_LOGV(TAG, "Cannot decode Length Delimited field %" PRIu32 "!", field_id); - } - ptr += field_length; - break; - } - case WIRE_TYPE_FIXED32: { // 32-bit - if (end - ptr < 4) { - ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); - return; - } - uint32_t val; -#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - // Protobuf fixed32 is little-endian — direct load on LE platforms - memcpy(&val, ptr, 4); -#else - val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); -#endif - if (!this->decode_32bit(field_id, Proto32Bit(val))) { - ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); - } - ptr += 4; - break; - } - default: - ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer)); + if (field_type == WIRE_TYPE_VARINT) [[likely]] { + if (!read_varint(scalar)) { + ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); return; + } + } else { + switch (field_type) { + case WIRE_TYPE_LENGTH_DELIMITED: { + proto_varint_value_t length_value; + if (!read_varint(length_value)) { + ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); + return; + } + uint32_t field_length = static_cast(length_value); + if (field_length > static_cast(end - ptr)) { + ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); + return; + } + data = ptr; + scalar = field_length; + ptr += field_length; + break; + } + case WIRE_TYPE_FIXED32: { + if (end - ptr < 4) { + ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); + return; + } + // Byte loads instead of memcpy: ESP-IDF passes -fno-builtin-memcpy, which made this a call + scalar = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); + ptr += 4; + break; + } + default: + ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer)); + return; + } } + this->decode_field(tag, data, scalar); } } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f2c21736747..1a5160cb82c 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -170,40 +170,43 @@ class ProtoVarInt { class ProtoMessage; class ProtoSize; -class ProtoLengthDelimited { +/// Case label for decode_field(): the wire tag of a field, so a field that arrives with another wire +/// type matches no case. +constexpr uint32_t proto_tag(uint32_t field_id, uint32_t wire_type) { return (field_id << 3) | wire_type; } + +/// One decoded field: the payload pointer and a scalar holding the varint or fixed32 value, or the +/// length of a length-delimited field. The wire type in the tag says which applies; accessors do not check. +class ProtoFieldValue { public: - explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} - std::string as_string() const { return std::string(reinterpret_cast(this->value_), this->length_); } + ProtoFieldValue(const uint8_t *data, proto_varint_value_t scalar) : data_(data), scalar_(scalar) {} - // Direct access to raw data without string allocation - const uint8_t *data() const { return this->value_; } - size_t size() const { return this->length_; } + proto_varint_value_t as_varint() const { return this->scalar_; } + // A bool is sent as 0 or 1, so the low word is enough and saves a second compare with 64 bit varints + bool as_bool() const { return static_cast(this->scalar_) != 0; } - /// Decode the length-delimited data into a message instance. + // Length-delimited accessors + const uint8_t *data() const { return this->data_; } + size_t size() const { return static_cast(this->scalar_); } + std::string as_string() const { return std::string(reinterpret_cast(this->data_), this->size()); } + /// Decode the length-delimited payload into a message instance. /// Template preserves concrete type so decode() resolves statically. - template void decode_to_message(T &msg) const; + template void decode_to_message(T &msg) const { msg.decode(this->data_, this->size()); } - protected: - const uint8_t *const value_; - const size_t length_; -}; - -class Proto32Bit { - public: - explicit Proto32Bit(uint32_t value) : value_(value) {} - uint32_t as_fixed32() const { return this->value_; } - int32_t as_sfixed32() const { return static_cast(this->value_); } + // Fixed32 accessors + uint32_t as_fixed32() const { return static_cast(this->scalar_); } + int32_t as_sfixed32() const { return static_cast(this->as_fixed32()); } float as_float() const { union { uint32_t raw; float value; } s{}; - s.raw = this->value_; + s.raw = this->as_fixed32(); return s.value; } - protected: - const uint32_t value_; + private: + const uint8_t *data_; + proto_varint_value_t scalar_; }; // NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported @@ -255,7 +258,7 @@ class ProtoWriteBuffer { * * Following https://protobuf.dev/programming-guides/encoding/#structure */ - void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw((field_id << 3) | type); } + void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); } /// Single-pass encode for repeated submessage elements. /// Thin template wrapper; all buffer work is in the non-template core. template void encode_sub_message(uint32_t field_id, const T &value); @@ -385,7 +388,7 @@ class ProtoEncode { } [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { - return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); + return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, proto_tag(field_id, type)); } /// Write a single precomputed tag byte. Tag must be < 128. [[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE @@ -735,10 +738,10 @@ class ProtoDecodableMessage : public ProtoMessage { protected: ~ProtoDecodableMessage() = default; - virtual bool decode_varint(uint32_t field_id, proto_varint_value_t value) { return false; } - virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } - virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } - // NOTE: decode_64bit removed - wire type 1 not supported + /// Store one decoded field; \p scalar is the varint or fixed32 value, or the length of the + /// length-delimited payload at \p data. An unknown field or wrong wire type matches no case and is skipped. + /// Three register arguments keep the decode loop free of spills. + virtual void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {} }; class ProtoSize { @@ -864,7 +867,7 @@ class ProtoSize { * @return The number of bytes needed to encode the field ID and wire type */ static constexpr uint32_t field(uint32_t field_id, uint32_t type) { - uint32_t tag = (field_id << 3) | (type & WIRE_TYPE_MASK); + uint32_t tag = proto_tag(field_id, type & WIRE_TYPE_MASK); return varint(tag); } @@ -958,11 +961,6 @@ template inline void ProtoWriteBuffer::encode_optional_sub_message(u this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg); } -// Template decode_to_message - preserves concrete type so decode() resolves statically -template void ProtoLengthDelimited::decode_to_message(T &msg) const { - msg.decode(this->value_, this->length_); -} - template const char *proto_enum_to_string(T value); // ProtoService removed — its methods were inlined into APIConnection. diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index cf9a20c46da..e63e28096e1 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -28,6 +28,11 @@ class WireType(IntEnum): END_GROUP = 4 # groups (deprecated) FIXED32 = 5 # fixed32, sfixed32, float + @property + def cpp_name(self) -> str: + """The matching constant in proto.h.""" + return f"WIRE_TYPE_{self.name}" + # Generate with # protoc --python_out=script/api_protobuf -I esphome/components/api/ api_options.proto @@ -224,41 +229,23 @@ class TypeInfo(ABC): def class_member(self) -> str: return f"{self.cpp_type} {self.field_name}{{{self.default_value}}};" - @property - def decode_varint_content(self) -> str: - content = self.decode_varint - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" + def decode_case(self, body: str) -> str: + """Emit one decode_field() case, keyed on the field's wire tag.""" + return f"case proto_tag({self.number}, {self.wire_type.cpp_name}):\n" + indent( + f"{body}\nbreak;" + ) - decode_varint = None + # Expression that reads this field from `value`; None when the type is never decoded. + decode_expr: str | None = None + + def _decode_store(self, expr: str) -> str: + return f"this->{self.field_name} = {expr};" @property - def decode_length_content(self) -> str: - content = self.decode_length - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_length = None - - @property - def decode_32bit_content(self) -> str: - content = self.decode_32bit - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_32bit = None - - @property - def decode_64bit_content(self) -> str: - content = self.decode_64bit - if content is None: - return None - return f"case {self.number}: this->{self.field_name} = {content}; break;" - - decode_64bit = None + def decode_content(self) -> str | None: + """The decode_field() case for this field, or None when it is never decoded.""" + expr = self.decode_expr + return None if expr is None else self.decode_case(self._decode_store(expr)) # Mapping from encode_func to raw encode expression template. # When a forced field has a single-byte tag, the code generator emits @@ -334,11 +321,12 @@ class TypeInfo(ABC): ) ) - def _encode_fixed32_with_precomputed_tag(self, value_expr: str) -> str | None: - """Single-byte tag fixed32 write, or None for multi-byte tags.""" + def _encode_fixed32_with_precomputed_tag(self, value: str) -> str | None: + """Single-byte tag fixed32 write, or None for other types and multi-byte tags.""" tag = self.calculate_tag() - if tag >= 128: + if self.fixed32_value_template is None or tag >= 128: return None + value_expr = self.fixed32_value_template.format(value=value) if self.force: return _encode_call("write_tag_and_fixed32", str(tag), value_expr) return ( @@ -352,14 +340,14 @@ class TypeInfo(ABC): value = f"this->{self.field_name}" if result := self._encode_with_precomputed_tag(value): return result - if self.fixed32_value_template is not None and ( - result := self._encode_fixed32_with_precomputed_tag( - self.fixed32_value_template.format(value=value) - ) - ): + if result := self._encode_fixed32_with_precomputed_tag(value): return result return _encode_call(self.encode_func, str(self.number), value, force=self.force) + def encode_element(self, number: int, element: str) -> str: + """Encode one element of a repeated field; elements are always written.""" + return _encode_call(self.encode_func, str(number), element, force=True) + encode_func = None @classmethod @@ -633,7 +621,6 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo): # Unsupported but defined for completeness cpp_type = "double" default_value = "0.0" - decode_64bit = "value.as_double()" encode_func = "encode_double" wire_type = WireType.FIXED64 # Uses wire type 1 according to protobuf spec @@ -659,7 +646,7 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo): class FloatType(FixedSizeTypeMixin, TypeInfo): cpp_type = "float" default_value = "0.0f" - decode_32bit = "value.as_float()" + decode_expr = "value.as_float()" encode_func = "encode_float" wire_type = WireType.FIXED32 # Uses wire type 5 @@ -688,7 +675,7 @@ class Int64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "static_cast(value)" + decode_expr = "static_cast(value.as_varint())" encode_func = "encode_int64" wire_type = WireType.VARINT # Uses wire type 0 @@ -709,7 +696,7 @@ class UInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "uint64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "value" + decode_expr = "value.as_varint()" encode_func = "encode_uint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -744,7 +731,7 @@ class Int32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" _varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf default_value = "0" - decode_varint = "static_cast(value)" + decode_expr = "static_cast(value.as_varint())" encode_func = "encode_int32" wire_type = WireType.VARINT # Uses wire type 0 @@ -764,7 +751,6 @@ class Int32Type(VarintTypeMixin, TypeInfo): class Fixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint64_t" default_value = "0" - decode_64bit = "value.as_fixed64()" encode_func = "encode_fixed64" wire_type = WireType.FIXED64 # Uses wire type 1 @@ -790,7 +776,7 @@ class Fixed64Type(FixedSizeTypeMixin, TypeInfo): class Fixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint32_t" default_value = "0" - decode_32bit = "value.as_fixed32()" + decode_expr = "value.as_fixed32()" encode_func = "encode_fixed32" wire_type = WireType.FIXED32 # Uses wire type 5 @@ -819,7 +805,7 @@ class BoolType(VarintTypeMixin, TypeInfo): _varint_max_bits = 1 cpp_type = "bool" default_value = "false" - decode_varint = "value != 0" + decode_expr = "value.as_bool()" encode_func = "encode_bool" wire_type = WireType.VARINT # Uses wire type 0 @@ -839,7 +825,7 @@ class StringType(TypeInfo): default_value = "" reference_type = "std::string &" const_reference_type = "const std::string &" - decode_length = "value.as_string()" + decode_expr = "value.as_string()" encode_func = "encode_string" wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2 @@ -954,6 +940,9 @@ class MessageType(TypeInfo): def can_use_dump_field(cls) -> bool: return False + def encode_element(self, number: int, element: str) -> str: + return _encode_call("encode_sub_message", "buffer", str(number), element) + @property def cpp_type(self) -> str: return self._field.type_name[1:] @@ -980,14 +969,6 @@ class MessageType(TypeInfo): self.encode_func, "buffer", str(self.number), f"this->{self.field_name}" ) - @property - def decode_length(self) -> str: - # Override to return None for message types because we can't use template-based - # decoding when the specific message type isn't known at compile time. - # Instead, we use the non-template decode_to_message() method which allows - # runtime polymorphism through virtual function calls. - return None - @property def public_content(self) -> list[str]: content = [self.class_member] @@ -1003,19 +984,14 @@ class MessageType(TypeInfo): ) @property - def decode_length_content(self) -> str: - # Custom decode that doesn't use templates + def decode_content(self) -> str: + body = f"value.decode_to_message(this->{self.field_name});" if self._track_presence: # decode_to_message() cannot report failure, so setting the flag # afterwards only documents intent; a status-returning decode could # gate it for real without touching callers. - return ( - f"case {self.number}:\n" - f" value.decode_to_message(this->{self.field_name});\n" - f" this->has_{self.name} = true;\n" - f" break;" - ) - return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;" + body += f"\nthis->has_{self.name} = true;" + return self.decode_case(body) def dump(self, name: str) -> str: return f"{name}.dump_to(out);" @@ -1054,7 +1030,7 @@ class BytesType(TypeInfo): reference_type = "std::string &" const_reference_type = "const std::string &" encode_func = "encode_bytes" - decode_length = "value.as_string()" + decode_expr = "value.as_string()" wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2 @property @@ -1164,11 +1140,6 @@ class PointerToBufferTypeBase(TypeInfo): super().__init__(field) self.array_size = 0 - @property - def decode_length(self) -> str | None: - # This is handled in decode_length_content - return None - @property def wire_type(self) -> WireType: """Get the wire type for this field.""" @@ -1210,12 +1181,11 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) @property - def decode_length_content(self) -> str | None: - return f"""case {self.number}: {{ - this->{self.field_name} = value.data(); - this->{self.field_name}_len = value.size(); - break; - }}""" + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name} = value.data();\n" + f"this->{self.field_name}_len = value.size();", + ) def dump(self, name: str) -> str: return ( @@ -1275,11 +1245,10 @@ class PointerToStringBufferType(PointerToBufferTypeBase): ) @property - def decode_length_content(self) -> str | None: - return f"""case {self.number}: {{ - this->{self.field_name} = StringRef(reinterpret_cast(value.data()), value.size()); - break; - }}""" + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name} = StringRef(value.data(), value.size());", + ) def dump(self, name: str) -> str: # Not used since we use dump_field, but required by abstract base class @@ -1348,14 +1317,13 @@ class PackedBufferTypeInfo(TypeInfo): ] @property - def decode_length_content(self) -> str: + def decode_content(self) -> str: """Store pointer to buffer and calculate count of packed varints.""" - return f"""case {self.number}: {{ - this->{self.field_name}_data_ = value.data(); - this->{self.field_name}_length_ = value.size(); - this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size()); - break; - }}""" + return self.decode_case( + f"this->{self.field_name}_data_ = value.data();\n" + f"this->{self.field_name}_length_ = value.size();\n" + f"this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size());", + ) @property def encode_content(self) -> str: @@ -1440,17 +1408,11 @@ class FixedArrayBytesType(TypeInfo): ] @property - def decode_length_content(self) -> str: - o = f"case {self.number}: {{\n" - o += " const std::string &data_str = value.as_string();\n" - o += f" this->{self.field_name}_len = data_str.size();\n" - o += f" if (this->{self.field_name}_len > {self.array_size}) {{\n" - o += f" this->{self.field_name}_len = {self.array_size};\n" - o += " }\n" - o += f" memcpy(this->{self.field_name}, data_str.data(), this->{self.field_name}_len);\n" - o += " break;\n" - o += "}" - return o + def decode_content(self) -> str: + return self.decode_case( + f"this->{self.field_name}_len = std::min(value.size(), {self.array_size});\n" + f"memcpy(this->{self.field_name}, value.data(), this->{self.field_name}_len);", + ) @property def encode_content(self) -> str: @@ -1513,7 +1475,7 @@ class UInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "uint32_t" _varint_max_bits = 32 default_value = "0" - decode_varint = "value" + decode_expr = "value.as_varint()" encode_func = "encode_uint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1536,13 +1498,21 @@ class UInt32Type(VarintTypeMixin, TypeInfo): class EnumType(VarintTypeMixin, TypeInfo): _varint_max_bits = 32 + def encode_element(self, number: int, element: str) -> str: + return _encode_call( + self.encode_func, + str(number), + f"static_cast({element})", + force=True, + ) + @property def cpp_type(self) -> str: return f"enums::{self._field.type_name[1:]}" @property - def decode_varint(self) -> str: - return f"static_cast<{self.cpp_type}>(value)" + def decode_expr(self) -> str: + return f"static_cast<{self.cpp_type}>(value.as_varint())" default_value = "" wire_type = WireType.VARINT # Uses wire type 0 @@ -1589,7 +1559,7 @@ class EnumType(VarintTypeMixin, TypeInfo): class SFixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_32bit = "value.as_sfixed32()" + decode_expr = "value.as_sfixed32()" encode_func = "encode_sfixed32" wire_type = WireType.FIXED32 # Uses wire type 5 @@ -1615,7 +1585,6 @@ class SFixed32Type(FixedSizeTypeMixin, TypeInfo): class SFixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_64bit = "value.as_sfixed64()" encode_func = "encode_sfixed64" wire_type = WireType.FIXED64 # Uses wire type 1 @@ -1642,7 +1611,7 @@ class SInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" _varint_max_bits = 32 # zigzag encoding keeps it 32-bit default_value = "0" - decode_varint = "decode_zigzag32(static_cast(value))" + decode_expr = "decode_zigzag32(static_cast(value.as_varint()))" encode_func = "encode_sint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1663,7 +1632,7 @@ class SInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" _varint_max_bits = 64 default_value = "0" - decode_varint = "decode_zigzag64(value)" + decode_expr = "decode_zigzag64(value.as_varint())" encode_func = "encode_sint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -1816,23 +1785,11 @@ class FixedArrayRepeatedType(TypeInfo): def _encode_element(self, element: str) -> str: """Helper to generate encode statement for a single element.""" - if isinstance(self._ti, EnumType): - return _encode_call( - self._ti.encode_func, - str(self.number), - f"static_cast({element})", - force=True, + if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type): + return _generate_inline_encode_block( + self.number, self._ti.cpp_type, element ) - # Repeated message elements use encode_sub_message (force=true is default) - if isinstance(self._ti, MessageType): - if _is_inline_encode(self._ti.cpp_type): - return _generate_inline_encode_block( - self.number, self._ti.cpp_type, element - ) - return _encode_call( - "encode_sub_message", "buffer", str(self.number), element - ) - return _encode_call(self._ti.encode_func, str(self.number), element, force=True) + return self._ti.encode_element(self.number, element) @property def cpp_type(self) -> str: @@ -2126,55 +2083,23 @@ class RepeatedTypeInfo(TypeInfo): return self._ti.wire_type @property - def decode_varint_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_varint - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) + def decode_expr(self) -> str | None: + return self._ti.decode_expr + + def _decode_store(self, expr: str) -> str: + return f"this->{self.field_name}.push_back({expr});" @property - def decode_length_content(self) -> str: + def decode_content(self) -> str | None: # Pointer fields don't support decoding if self._use_pointer: return None - content = self._ti.decode_length - if content is None and isinstance(self._ti, MessageType): - # Special handling for non-template message decoding - return f"case {self.number}: this->{self.field_name}.emplace_back(); value.decode_to_message(this->{self.field_name}.back()); break;" - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) - - @property - def decode_32bit_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_32bit - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) - - @property - def decode_64bit_content(self) -> str: - # Pointer fields don't support decoding - if self._use_pointer: - return None - content = self._ti.decode_64bit - if content is None: - return None - return ( - f"case {self.number}: this->{self.field_name}.push_back({content}); break;" - ) + if isinstance(self._ti, MessageType): + return self.decode_case( + f"this->{self.field_name}.emplace_back();\n" + f"value.decode_to_message(this->{self.field_name}.back());" + ) + return super().decode_content @property def _ti_is_bool(self) -> bool: @@ -2182,20 +2107,7 @@ class RepeatedTypeInfo(TypeInfo): return isinstance(self._ti, BoolType) def _encode_element_call(self, element: str) -> str: - """Helper to generate encode call for a single element.""" - if isinstance(self._ti, EnumType): - return _encode_call( - self._ti.encode_func, - str(self.number), - f"static_cast({element})", - force=True, - ) - # Repeated message elements use encode_sub_message (force=true is default) - if isinstance(self._ti, MessageType): - return _encode_call( - "encode_sub_message", "buffer", str(self.number), element - ) - return _encode_call(self._ti.encode_func, str(self.number), element, force=True) + return self._ti.encode_element(self.number, element) @property def encode_content(self) -> str: @@ -2590,10 +2502,7 @@ def build_message_type( ) -> tuple[str, str, str]: public_content: list[str] = [] protected_content: list[str] = [] - decode_varint: list[str] = [] - decode_length: list[str] = [] - decode_32bit: list[str] = [] - decode_64bit: list[str] = [] + decode: list[str] = [] encode: list[str] = [] dump: list[str] = [] size_calc: list[str] = [] @@ -2722,22 +2631,8 @@ def build_message_type( if field.options.HasExtension(pb.field_ifdef): field_ifdef = field.options.Extensions[pb.field_ifdef] - if ti.decode_varint_content: - decode_varint.extend( - wrap_with_ifdef(ti.decode_varint_content, field_ifdef) - ) - if ti.decode_length_content: - decode_length.extend( - wrap_with_ifdef(ti.decode_length_content, field_ifdef) - ) - if ti.decode_32bit_content: - decode_32bit.extend( - wrap_with_ifdef(ti.decode_32bit_content, field_ifdef) - ) - if ti.decode_64bit_content: - decode_64bit.extend( - wrap_with_ifdef(ti.decode_64bit_content, field_ifdef) - ) + if case := ti.decode_content: + decode.extend(wrap_with_ifdef(case, field_ifdef)) if ti.dump_content: # Check for field_ifdef option for dump as well field_ifdef = None @@ -2747,49 +2642,15 @@ def build_message_type( dump.extend(wrap_with_ifdef(ti.dump_content, field_ifdef)) cpp = "" - if decode_varint: - o = f"bool {desc.name}::decode_varint(uint32_t field_id, proto_varint_value_t value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_varint), " ") + "\n" - o += " default: return false;\n" + if decode: + o = f"void {desc.name}::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {{\n" + o += " const ProtoFieldValue value(data, scalar);\n" + o += " switch (tag) {\n" + o += indent("\n".join(decode), " ") + "\n" o += " }\n" - o += " return true;\n" o += "}\n" cpp += o - prot = "bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;" - protected_content.insert(0, prot) - if decode_length: - o = f"bool {desc.name}::decode_length(uint32_t field_id, ProtoLengthDelimited value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_length), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;" - protected_content.insert(0, prot) - if decode_32bit: - o = f"bool {desc.name}::decode_32bit(uint32_t field_id, Proto32Bit value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_32bit), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_32bit(uint32_t field_id, Proto32Bit value) override;" - protected_content.insert(0, prot) - if decode_64bit: - o = f"bool {desc.name}::decode_64bit(uint32_t field_id, Proto64Bit value) {{\n" - o += " switch (field_id) {\n" - o += indent("\n".join(decode_64bit), " ") + "\n" - o += " default: return false;\n" - o += " }\n" - o += " return true;\n" - o += "}\n" - cpp += o - prot = "bool decode_64bit(uint32_t field_id, Proto64Bit value) override;" + prot = "void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override;" protected_content.insert(0, prot) # Generate custom decode() override for messages with FixedVector fields diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index 05bbcc73dd8..e45966999bd 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -249,7 +249,7 @@ static APIBuffer build_infrared_rf_transmit_wire() { std::memcpy(bytes + len, packed, packed_len); len += packed_len; // field 6: modulation = 1 (non-zero so it's actually emitted and exercises - // decode_varint for this field, matching the documented layout above). + // decode_field for this field, matching the documented layout above). put_byte(0x30); put_varint(1); diff --git a/tests/integration/fixtures/api_decode_wire_types.yaml b/tests/integration/fixtures/api_decode_wire_types.yaml new file mode 100644 index 00000000000..2bb5028730b --- /dev/null +++ b/tests/integration/fixtures/api_decode_wire_types.yaml @@ -0,0 +1,43 @@ +esphome: + name: api-decode-wire-types-test +host: +api: +logger: + level: DEBUG + +switch: + - platform: template + name: "Wire Switch" + optimistic: true + +output: + - platform: template + id: wire_dim + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Wire Light" + output: wire_dim + default_transition_length: 0s + effects: + - pulse: + name: Pulse + +text: + - platform: template + name: "Wire Text" + optimistic: true + mode: text + min_length: 0 + max_length: 255 + +number: + - platform: template + name: "Wire Number" + optimistic: true + min_value: -1000 + max_value: 1000 + step: 0.5 diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index 1dbe40933c9..cc759f88e5a 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -125,11 +125,12 @@ class RawApiClient: await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse]) async def send_message(self, msg: message.Message) -> None: + await self.send_raw(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()) + + async def send_raw(self, msg_type: int, payload: bytes) -> None: + """Send a frame with a hand built payload, for shapes protobuf will not serialize.""" loop = asyncio.get_running_loop() - await loop.sock_sendall( - self._sock, - encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()), - ) + await loop.sock_sendall(self._sock, encode_frame(msg_type, payload)) async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None: """Read until at least one frame of msg_type has been received.""" diff --git a/tests/integration/test_api_decode_wire_types.py b/tests/integration/test_api_decode_wire_types.py new file mode 100644 index 00000000000..5c8444273a5 --- /dev/null +++ b/tests/integration/test_api_decode_wire_types.py @@ -0,0 +1,142 @@ +"""decode_field() must take fields that match their declared wire type, drop the ones that do +not, skip unknown fields, and handle two byte tags, varints and length prefixes.""" + +from __future__ import annotations + +from collections.abc import Callable +import struct + +from aioesphomeapi import ( + EntityState, + LightState, + NumberState, + SwitchState, + TextState, + api_pb2, +) +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest] +WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5 + + +def tag(field: int, wire_type: int) -> bytes: + return encode_varint((field << 3) | wire_type) + + +@pytest.mark.asyncio +async def test_api_decode_wire_types( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + unused_tcp_port: int, +) -> None: + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + RawApiClient(unused_tcp_port) as raw, + ): + entities, _ = await client.list_entities_services() + switch = require_entity(entities, "wire_switch") + light = require_entity(entities, "wire_light") + text = require_entity(entities, "wire_text") + number = require_entity(entities, "wire_number") + key = tag(1, WIRE_FIXED32) + struct.pack(" None: + if isinstance(state, SwitchState) and state.key == switch.key: + switch_states.append(state.state) + waiter.on_state(state) + + def switch_is(value: bool) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, SwitchState) and s.key == switch.key and s.state is value + ) + + def number_is(value: float) -> Callable[[EntityState], bool]: + return lambda s: ( + isinstance(s, NumberState) and s.key == number.key and s.state == value + ) + + initial = InitialStateHelper(entities) + client.subscribe_states(initial.on_state_wrapper(on_state)) + await initial.wait_for_initial_states() + await raw.connect() + + # A well formed command: fixed32 key, varint state + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True)) + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # The same field with the wrong wire type is dropped, and a varint key never matches an + # entity; each of these would turn the switch on if the payload were read as a varint + seen = len(switch_states) + await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x01\x01") + await raw.send_raw( + SWITCH_COMMAND, key + tag(2, WIRE_FIXED32) + b"\x01\x00\x00\x00" + ) + await raw.send_raw( + SWITCH_COMMAND, tag(1, WIRE_VARINT) + encode_varint(switch.key) + on + ) + # Ordered on the raw socket itself: this frame cannot be parsed before the bad ones, so + # the only switch state since the marker must be the one it produces + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True), label="switch on after wrong wire types") + assert switch_states[seen:] == [True] + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # Truncated bodies stop the decode loop without taking the connection down: a tag with its + # continuation bit set and nothing after it, a length prefix past the end of the payload, + # and a fixed32 with two of its four bytes + seen = len(switch_states) + await raw.send_raw(SWITCH_COMMAND, key + b"\x80") + await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x7f" + b"ab") + await raw.send_raw(SWITCH_COMMAND, tag(1, WIRE_FIXED32) + b"\x01\x02") + await raw.send_raw(SWITCH_COMMAND, key + on) + await waiter.expect(switch_is(True), label="switch on after truncated frames") + assert switch_states[seen:] == [True] + await raw.send_raw(SWITCH_COMMAND, key + off) + await waiter.expect(switch_is(False)) + + # A negative number goes through the fixed32 float path of a normal client + client.number_command(number.key, -77.5) + await waiter.expect(number_is(-77.5)) + + # An unknown field ahead of the known ones is skipped; field 200 needs a two byte tag + await raw.send_raw( + SWITCH_COMMAND, tag(200, WIRE_VARINT) + encode_varint(300) + key + on + ) + await waiter.expect(switch_is(True)) + + # Two byte tags (effect fields 18 and 19) and a two byte varint (300 ms transition) + client.light_command( + light.key, state=True, brightness=0.5, transition_length=0.3, effect="Pulse" + ) + await waiter.expect( + lambda s: ( + isinstance(s, LightState) and s.key == light.key and s.effect == "Pulse" + ) + ) + client.light_command(light.key, effect="None", state=False) + await waiter.expect( + lambda s: isinstance(s, LightState) and s.key == light.key and not s.state + ) + + # A string whose length prefix needs two varint bytes + long_text = "w" * 200 + client.text_command(text.key, long_text) + await waiter.expect( + lambda s: ( + isinstance(s, TextState) and s.key == text.key and s.state == long_text + ) + ) diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index c182520c5ac..0e0a4f3fbf2 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -18,7 +18,9 @@ sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) import aioesphomeapi.api_options_pb2 as pb # noqa: E402 from api_protobuf import ( # noqa: E402 MAX_MESSAGE_ID, + SOURCE_CLIENT, _make_ifdef_line, + build_message_type, create_field_type_info, get_varint64_ifdef, validate_message_id, @@ -36,12 +38,15 @@ def _file_with_messages( file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") for name, field_type, deprecated in messages: msg = file_desc.message_type.add(name=name) - field = msg.field.add(name="value", number=1, type=field_type) + field = msg.field.add() + field.CopyFrom(_field(field_type)) field.options.deprecated = deprecated return file_desc UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +MESSAGE = descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE +DOUBLE = descriptor_pb2.FieldDescriptorProto.TYPE_DOUBLE INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 @@ -182,3 +187,106 @@ def test_multi_byte_tag_fixed32_falls_back_to_the_generic_helper( content = _encode_field(field_type, number=16) assert "write_tag_and_fixed32" not in content, content assert content.startswith("pos = ProtoEncode::encode_"), content + + +def _decode_case(field_type: int, number: int, *, repeated: bool = False) -> str: + """Return the decode_field() case the generator emits for one decoded field.""" + field = _field(field_type, number, repeated=repeated) + if field_type == MESSAGE: + field.type_name = ".Sub" + return create_field_type_info( + field, needs_decode=True, needs_encode=False + ).decode_content + + +@pytest.mark.parametrize( + ("field_type", "number", "wire_type", "accessor"), + [ + (UINT32, 2, "WIRE_TYPE_VARINT", "value.as_varint()"), + (BOOL, 3, "WIRE_TYPE_VARINT", "value.as_bool()"), + (STRING, 1, "WIRE_TYPE_LENGTH_DELIMITED", "value.data()"), + (FLOAT, 4, "WIRE_TYPE_FIXED32", "value.as_float()"), + (FIXED32, 5, "WIRE_TYPE_FIXED32", "value.as_fixed32()"), + ], +) +def test_decode_cases_carry_field_number_and_wire_type( + field_type: int, number: int, wire_type: str, accessor: str +) -> None: + """Each decoded field yields one case keyed on its number and declared wire type.""" + case = _decode_case(field_type, number) + lines = case.splitlines() + assert lines[0] == f"case proto_tag({number}, {wire_type}):", case + assert accessor in lines[1], case + assert lines[-1].strip() == "break;", case + + +@pytest.mark.parametrize( + ("field_type", "repeated", "wire_type", "store"), + [ + (UINT32, True, "WIRE_TYPE_VARINT", "this->value.push_back(value.as_varint());"), + ( + STRING, + True, + "WIRE_TYPE_LENGTH_DELIMITED", + "this->value.push_back(value.as_string());", + ), + ( + MESSAGE, + False, + "WIRE_TYPE_LENGTH_DELIMITED", + "value.decode_to_message(this->value);", + ), + ( + MESSAGE, + True, + "WIRE_TYPE_LENGTH_DELIMITED", + "value.decode_to_message(this->value.back());", + ), + ], +) +def test_repeated_and_message_fields_decode_through_the_same_case_shape( + field_type: int, repeated: bool, wire_type: str, store: str +) -> None: + """Repeated and sub message fields land in the one switch with their own store.""" + case = _decode_case(field_type, 7, repeated=repeated) + lines = case.splitlines() + assert lines[0] == f"case proto_tag(7, {wire_type}):", case + assert store in case, case + if field_type == MESSAGE and repeated: + assert "this->value.emplace_back();" in case, case + assert lines[-1].strip() == "break;", case + + +def test_a_fixed64_field_fails_at_generation_time() -> None: + """The decode loop has no 64 bit wire type path, so such a field must never reach it silently.""" + desc = descriptor_pb2.DescriptorProto(name="Wide") + desc.field.add(name="ratio", number=1, type=DOUBLE) + with pytest.raises( + ValueError, match="64-bit type 'double' .*ratio.* not supported" + ): + build_message_type(desc, {}, {"Wide": SOURCE_CLIENT}) + + +def test_message_gets_a_single_decode_field_override() -> None: + """All wire types of a decoded message land in one decode_field() switch.""" + desc = descriptor_pb2.DescriptorProto(name="Mixed") + desc.field.add(name="name", number=1, type=STRING) + desc.field.add(name="count", number=2, type=UINT32) + desc.field.add(name="level", number=3, type=FLOAT) + header, cpp, _ = build_message_type(desc, {}, {"Mixed": SOURCE_CLIENT}) + decl = "void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override;" + assert header.count(decl) == 1 + assert ( + cpp.count( + "void Mixed::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {" + ) + == 1 + ) + assert "switch (tag) {" in cpp + assert "const ProtoFieldValue value(data, scalar);" in cpp + for number, wire_type in ( + (1, "WIRE_TYPE_LENGTH_DELIMITED"), + (2, "WIRE_TYPE_VARINT"), + (3, "WIRE_TYPE_FIXED32"), + ): + assert f"case proto_tag({number}, {wire_type}):" in cpp, cpp From d2fefe229954fa70a8a9d8fea22b4222f86890a7 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 24 Sep 2026 16:45:51 +0200 Subject: [PATCH 442/759] [TAS2780] Add TAS2780 audio amplifier (#15708) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tas2780/__init__.py | 1 + esphome/components/tas2780/audio_dac.py | 108 +++++ esphome/components/tas2780/tas2780.cpp | 406 ++++++++++++++++++ esphome/components/tas2780/tas2780.h | 65 +++ tests/components/tas2780/common.yaml | 31 ++ tests/components/tas2780/test.esp32-idf.yaml | 3 + .../components/tas2780/test.esp8266-ard.yaml | 3 + tests/components/tas2780/test.rp2040-ard.yaml | 3 + 9 files changed, 621 insertions(+) create mode 100644 esphome/components/tas2780/__init__.py create mode 100644 esphome/components/tas2780/audio_dac.py create mode 100644 esphome/components/tas2780/tas2780.cpp create mode 100644 esphome/components/tas2780/tas2780.h create mode 100644 tests/components/tas2780/common.yaml create mode 100644 tests/components/tas2780/test.esp32-idf.yaml create mode 100644 tests/components/tas2780/test.esp8266-ard.yaml create mode 100644 tests/components/tas2780/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 5c7063f0a54..4065e52c2c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,6 +549,7 @@ esphome/components/sx127x/* @swoboda1337 esphome/components/sy6970/* @linkedupbits esphome/components/syslog/* @clydebarrow esphome/components/t6615/* @tylermenezes +esphome/components/tas2780/* @remcom esphome/components/tc74/* @sethgirvan esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski diff --git a/esphome/components/tas2780/__init__.py b/esphome/components/tas2780/__init__.py new file mode 100644 index 00000000000..81e00ca74ba --- /dev/null +++ b/esphome/components/tas2780/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@remcom"] diff --git a/esphome/components/tas2780/audio_dac.py b/esphome/components/tas2780/audio_dac.py new file mode 100644 index 00000000000..9fbee8cc297 --- /dev/null +++ b/esphome/components/tas2780/audio_dac.py @@ -0,0 +1,108 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.audio_dac import AudioDac +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID, CONF_POWER_MODE +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +tas2780_ns = cg.esphome_ns.namespace("tas2780") +TAS2780 = tas2780_ns.class_("TAS2780", AudioDac, cg.PollingComponent, i2c.I2CDevice) +ChannelSelect = tas2780_ns.enum("ChannelSelect") + +CHANNELS = { + "mono": ChannelSelect.MONO_DWN_MIX, + "left": ChannelSelect.LEFT_CHANNEL, + "right": ChannelSelect.RIGHT_CHANNEL, +} + +CONF_VOL_RANGE_MIN = "vol_range_min" +CONF_VOL_RANGE_MAX = "vol_range_max" +CONF_AMP_LEVEL = "amp_level" + +_AMP_LEVEL = cv.int_range(min=0, max=20) +_POWER_MODE = cv.int_range(min=0, max=3) + + +def _validate_vol_range(config: ConfigType) -> ConfigType: + if config[CONF_VOL_RANGE_MIN] >= config[CONF_VOL_RANGE_MAX]: + raise cv.Invalid(f"{CONF_VOL_RANGE_MIN} must be less than {CONF_VOL_RANGE_MAX}") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TAS2780), + cv.Optional(CONF_AMP_LEVEL, default=8): _AMP_LEVEL, + cv.Optional(CONF_POWER_MODE, default=2): _POWER_MODE, + cv.Optional(CONF_VOL_RANGE_MIN, default=0.3): cv.percentage, + cv.Optional(CONF_VOL_RANGE_MAX, default=1.0): cv.percentage, + cv.Optional(CONF_CHANNEL, default="mono"): cv.enum(CHANNELS), + } + ) + .extend(cv.polling_component_schema("5s")) + .extend(i2c.i2c_device_schema(0x38)), + _validate_vol_range, +) + + +TAS2780_ACTION_SCHEMA = maybe_simple_id({cv.GenerateID(): cv.use_id(TAS2780)}) + +for _name, _call in ( + ("tas2780.deactivate", "deactivate()"), + ("tas2780.reset", "reset()"), +): + automation.register_apply_action( + _name, TAS2780_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + +# Without power_mode the configured mode stays; activate() re-initializes only on a change. +automation.register_apply_action( + "tas2780.activate", + maybe_simple_id( + { + cv.GenerateID(): cv.use_id(TAS2780), + cv.Optional(CONF_POWER_MODE): cv.templatable(_POWER_MODE), + } + ), + automation.ApplyField(CONF_POWER_MODE, "set_power_mode", cg.uint8), + automation.ApplyCall("activate()"), +) + +automation.register_apply_action( + "tas2780.update_config", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TAS2780), + cv.Optional(CONF_VOL_RANGE_MIN): cv.templatable(cv.percentage), + cv.Optional(CONF_VOL_RANGE_MAX): cv.templatable(cv.percentage), + cv.Optional(CONF_AMP_LEVEL): cv.templatable(_AMP_LEVEL), + cv.Optional(CONF_CHANNEL): cv.templatable(cv.enum(CHANNELS)), + } + ).add_extra( + cv.has_at_least_one_key( + CONF_VOL_RANGE_MIN, CONF_VOL_RANGE_MAX, CONF_AMP_LEVEL, CONF_CHANNEL + ) + ), + automation.ApplyField(CONF_AMP_LEVEL, "set_amp_level", cg.uint8), + automation.ApplyField(CONF_VOL_RANGE_MIN, "set_vol_range_min", cg.float_), + automation.ApplyField(CONF_VOL_RANGE_MAX, "set_vol_range_max", cg.float_), + automation.ApplyField(CONF_CHANNEL, "set_selected_channel", ChannelSelect), + automation.ApplyCall("apply_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) + + cg.add(var.set_amp_level(config[CONF_AMP_LEVEL])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_vol_range_min(config[CONF_VOL_RANGE_MIN])) + cg.add(var.set_vol_range_max(config[CONF_VOL_RANGE_MAX])) + cg.add(var.set_selected_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/tas2780/tas2780.cpp b/esphome/components/tas2780/tas2780.cpp new file mode 100644 index 00000000000..191dbf823c3 --- /dev/null +++ b/esphome/components/tas2780/tas2780.cpp @@ -0,0 +1,406 @@ +#include "tas2780.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::tas2780 { + +static const char *const TAG = "tas2780"; + +static constexpr uint8_t TAS2780_PAGE_SELECT = 0x00; // Page Select +static constexpr uint8_t TAS2780_PAGE_0 = 0x00; // Page 0 +static constexpr uint8_t TAS2780_PAGE_1 = 0x01; // Page 1 +static constexpr uint8_t TAS2780_PAGE_FD = 0xFD; // Page 0xFD + +/* PAGE 0 */ +static constexpr uint8_t TAS2780_SW_RESET = 0x01; // Software Reset +static constexpr uint8_t TAS2780_SW_RESET_CMD = 0x01; // Trigger software reset +static constexpr uint8_t TAS2780_MODE_CTRL = 0x02; // Device operational mode +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_MASK = 0x07; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_ACTIVE = 0x00; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED = 0x01; +static constexpr uint8_t TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN = 0x02; + +static constexpr uint8_t TAS2780_CHNL_0 = 0x03; // Y Bridge and Channel settings +static constexpr uint8_t TAS2780_CHNL_0_CDS_MODE_SHIFT = 6; +static constexpr uint8_t TAS2780_CHNL_0_CDS_MODE_MASK = (0x03 << TAS2780_CHNL_0_CDS_MODE_SHIFT); +static constexpr uint8_t TAS2780_CHNL_0_AMP_LEVEL_SHIFT = 1; +static constexpr uint8_t TAS2780_CHNL_0_AMP_LEVEL_MASK = (0x1F) << TAS2780_CHNL_0_AMP_LEVEL_SHIFT; +static constexpr uint8_t TAS2780_AMP_LEVEL_MAX = 0x14; // Codes above 20 are reserved + +static constexpr uint8_t TAS2780_DC_BLK0 = 0x04; // SAR Filter and DC Path Blocker +static constexpr uint8_t TAS2780_DC_BLK0_VBAT1S_MODE_MASK = (1 << 7); +static constexpr uint8_t TAS2780_DC_BLK1 = 0x05; // Record DC Blocker +static constexpr uint8_t TAS2780_DC_BLK1_RESET_VAL = 0x41; // Presence check, there is no WHO_AM_I register + +static constexpr uint8_t TAS2780_TDM_CFG2 = 0x0A; // TDM Configuration 2 +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SCFG_SHIFT = 4; +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SCFG_MASK = (3 << TAS2780_TDM_CFG2_RX_SCFG_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_SHIFT = 2; +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_MASK = (3 << TAS2780_TDM_CFG2_RX_WLEN_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_WLEN_32BIT = (3 << TAS2780_TDM_CFG2_RX_WLEN_SHIFT); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SLEN_MASK = (3 << 0); +static constexpr uint8_t TAS2780_TDM_CFG2_RX_SLEN_32BIT = 2; + +static constexpr uint8_t TAS2780_TDM_CFG5 = 0x0E; // TDM Configuration 5 +static constexpr uint8_t TAS2780_TDM_CFG5_TX_VSNS_EN_SLOT4 = 0x44; // vsns TX enable, slot 4 +static constexpr uint8_t TAS2780_TDM_CFG6 = 0x0F; // TDM Configuration 6 +static constexpr uint8_t TAS2780_TDM_CFG6_TX_ISNS_EN_SLOT0 = 0x40; // isns TX enable, slot 0 + +static constexpr uint8_t TAS2780_DVC = 0x1A; // Digital Volume Control +static constexpr uint8_t TAS2780_DVC_MAX_ATTEN = 200; // 0 dB (0x00) to -100 dB (0xC8) in 0.5 dB steps + +/* Interrupts */ +static constexpr uint8_t TAS2780_INT_MASK_ALL = 0xFF; // Mask all interrupts +static constexpr uint8_t TAS2780_INT_MASK0 = 0x3B; // Interrupt Mask 0 +static constexpr uint8_t TAS2780_INT_MASK1 = 0x3C; // Interrupt Mask 1 +static constexpr uint8_t TAS2780_INT_MASK1_0 = 0x3D; // Interrupt Mask 1_0 (INT_LTCH1_0 group) +static constexpr uint8_t TAS2780_INT_MASK2 = 0x40; // Interrupt Mask 2 +static constexpr uint8_t TAS2780_INT_MASK3 = 0x41; // Interrupt Mask 3 +static constexpr uint8_t TAS2780_INT_LTCH0 = 0x49; // Latched Interrupt Read-back 0, 1 and 1_0 follow +static constexpr uint8_t TAS2780_INT_LTCH2 = 0x4F; // Latched Interrupt Read-back 2 + +static constexpr uint8_t TAS2780_INT_CLK_CFG = 0x5C; // Clock Setting and IRQZ +static constexpr uint8_t TAS2780_INT_CLK_CFG_CLR_LATCH = (1 << 2); // Clear interrupt latches +static constexpr uint8_t TAS2780_INT_CLK_CFG_MODE_MASK = 0x03; // Trigger mode field mask +static constexpr uint8_t TAS2780_INT_CLK_CFG_MODE_LIVE = 0x00; // Trigger on any unmasked live interrupt +static constexpr uint8_t TAS2780_PVDD_UVLO = 0x71; // UVLO Threshold +static constexpr uint8_t TAS2780_PVDD_UVLO_2V76 = 0x03; // PVDD UVLO threshold = 2.76V + +/* PAGE 0x01 */ +static constexpr uint8_t TAS2780_INIT_0 = 0x17; // Initialization +static constexpr uint8_t TAS2780_INIT_0_VAL = 0xC8; // SARBurstMask=0, CMP_HYST_LP=1 +static constexpr uint8_t TAS2780_LSR = 0x19; // Modulation +static constexpr uint8_t TAS2780_LSR_PWM_MODE = 0x00; // PWM modulation mode +static constexpr uint8_t TAS2780_INIT_1 = 0x21; // Initialization +static constexpr uint8_t TAS2780_INIT_1_VAL = 0x00; // Disable comparator hysteresis +static constexpr uint8_t TAS2780_INIT_2 = 0x35; // Initialization +static constexpr uint8_t TAS2780_INIT_2_VAL = 0x74; // Noise minimized + +/* PAGE 0xFD */ +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS = 0x0D; // Page 0xFD access unlock/lock register +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS_UNLOCK = 0x0D; // Unlock page 0xFD access +static constexpr uint8_t TAS2780_PAGE_FD_ACCESS_LOCK = 0x00; // Lock page 0xFD access +static constexpr uint8_t TAS2780_INIT_3 = 0x3E; // Initialization +static constexpr uint8_t TAS2780_INIT_3_VAL = 0x4A; // Optimal Dmin + +// CDS_MODE (2 bits) and VBAT1S_MODE (1 bit) per power mode 0..3, packed so nothing lands in RAM on ESP8266: +// PWR_MODE0: 2/0, PWR_MODE1: 0/0, PWR_MODE2: 3/1, PWR_MODE3: 1/0 +static constexpr uint8_t POWER_MODE_CDS_MODES = 0x72; +static constexpr uint8_t POWER_MODE_VBAT1S_MODES = 0x04; + +// Latched interrupt bits per register (INT_LTCH0, 1, 1_0, 2, one byte each, low to high), split into faults +// and informational events; bits outside both masks are undefined. +static constexpr uint32_t TAS2780_INT_LTCH_ERROR_MASKS = 0x0FA058C7; +static constexpr uint32_t TAS2780_INT_LTCH_INFO_MASKS = 0x00002138; + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t reg, uint8_t bit) { + const uint8_t key = (reg << 3) | bit; + if (key == 0x00) + return LOG_STR("Over temperature error"); + if (key == 0x01) + return LOG_STR("Over current error"); + if (key == 0x02) + return LOG_STR("TDM Clock Error"); + if (key == 0x03) + return LOG_STR("Limiter active"); + if (key == 0x04) + return LOG_STR("PVDD below limiter inflection point"); + if (key == 0x05) + return LOG_STR("Limiter max attenuation"); + if (key == 0x06) + return LOG_STR("BOP infinite hold"); + if (key == 0x07) + return LOG_STR("BOP Mute"); + if (key == 0x08) + return LOG_STR("Gain limiter active"); + if (key == 0x0B) + return LOG_STR("Load Diagnostic mode fault status"); + if (key == 0x0D) + return LOG_STR("Load diagnostic complete"); + if (key == 0x0E) + return LOG_STR("OTP CRC error flag"); + if (key == 0x15) + return LOG_STR("VBAT1S Under Voltage"); + if (key == 0x17) + return LOG_STR("Internal PLL Clock Error"); + if (key == 0x18) + return LOG_STR("PVDD UVLO"); + if (key == 0x19) + return LOG_STR("Internal VBAT1S LDO Over Load"); + if (key == 0x1A) + return LOG_STR("Internal VBAT1S LDO Over Voltage"); + if (key == 0x1B) + return LOG_STR("Internal VBAT1S LDO Under Voltage"); + return nullptr; +} + +void TAS2780::setup() { + if (!this->init_()) { + this->mark_failed(); + return; + } + this->write_mode_ctrl_(TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN); +} + +bool TAS2780::select_page_(uint8_t page) { + if (this->current_page_ == page) + return true; + if (!this->write_byte(TAS2780_PAGE_SELECT, page)) { + this->current_page_ = 0xFF; + return false; + } + this->current_page_ = page; + return true; +} + +bool TAS2780::update_bits_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_byte(reg, ¤t)) { + ESP_LOGE(TAG, "Failed to read register 0x%02X", reg); + return false; + } + return this->write_byte(reg, (current & ~mask) | (value & mask)); +} + +bool TAS2780::init_() { + // Software reset (must select page 0 first; reset invalidates page cache) + if (!this->select_page_(TAS2780_PAGE_0)) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + this->current_page_ = 0xFF; + this->applied_power_mode_ = 0xFF; // only a complete init makes the chip trustworthy again + this->reg(TAS2780_SW_RESET) = TAS2780_SW_RESET_CMD; + delay(1); + + uint8_t chd1; + if (!this->read_byte(TAS2780_DC_BLK1, &chd1)) { + ESP_LOGE(TAG, "I2C read failed during init"); + return false; + } + if (chd1 != TAS2780_DC_BLK1_RESET_VAL) { + ESP_LOGE(TAG, "Init failed (DC_BLK1=0x%02X, expected 0x%02X)", chd1, TAS2780_DC_BLK1_RESET_VAL); + return false; + } + + if (!this->select_page_(TAS2780_PAGE_0)) { + return false; + } + this->reg(TAS2780_TDM_CFG5) = TAS2780_TDM_CFG5_TX_VSNS_EN_SLOT4; + this->reg(TAS2780_TDM_CFG6) = TAS2780_TDM_CFG6_TX_ISNS_EN_SLOT0; + + if (!this->select_page_(TAS2780_PAGE_1)) { + return false; + } + this->reg(TAS2780_LSR) = TAS2780_LSR_PWM_MODE; + this->reg(TAS2780_INIT_0) = TAS2780_INIT_0_VAL; + this->reg(TAS2780_INIT_1) = TAS2780_INIT_1_VAL; + this->reg(TAS2780_INIT_2) = TAS2780_INIT_2_VAL; + + if (!this->select_page_(TAS2780_PAGE_FD)) { + return false; + } + this->reg(TAS2780_PAGE_FD_ACCESS) = TAS2780_PAGE_FD_ACCESS_UNLOCK; + this->reg(TAS2780_INIT_3) = TAS2780_INIT_3_VAL; + this->reg(TAS2780_PAGE_FD_ACCESS) = TAS2780_PAGE_FD_ACCESS_LOCK; + + if (!this->select_page_(TAS2780_PAGE_0)) { + return false; + } + if (!this->set_power_mode_(this->power_mode_)) + return false; + + // When Y bridge is used (eg. PWR_MODE1) PVDD UVLO threshold needs to be set 2.5 V above VBAT1S level. + // UVLO = 1.753V + val * 0.332V + this->reg(TAS2780_PVDD_UVLO) = TAS2780_PVDD_UVLO_2V76; + + // Mask all interrupt groups on the IRQZ pin, events are polled via update() + this->reg(TAS2780_INT_MASK0) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK1) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK1_0) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK2) = TAS2780_INT_MASK_ALL; + this->reg(TAS2780_INT_MASK3) = TAS2780_INT_MASK_ALL; + + if (!this->update_bits_(TAS2780_INT_CLK_CFG, TAS2780_INT_CLK_CFG_MODE_MASK, TAS2780_INT_CLK_CFG_MODE_LIVE)) + return false; + + // Software reset sets DVC back to 0 dB (full volume) + if (!this->apply_config()) + return false; + this->applied_power_mode_ = this->power_mode_; + return true; +} + +void TAS2780::activate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "Activating (PWR_MODE:%u)", this->power_mode_); + this->clear_latches_(); + if (this->power_mode_ != this->applied_power_mode_ && !this->reinit_()) + return; + this->active_ = this->write_mode_ctrl_(this->active_mode_()); +} + +void TAS2780::deactivate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "Deactivating"); + this->active_ = false; + this->write_mode_ctrl_(TAS2780_MODE_CTRL_MODE_SFTW_SHTDWN); +} + +void TAS2780::reset() { + if (this->is_failed()) + return; + if (this->reinit_() && this->active_) + this->activate(); +} + +bool TAS2780::reinit_() { + if (!this->init_()) { + ESP_LOGE(TAG, "Re-initialization failed"); + this->status_set_error(LOG_STR("Init failed")); + return false; + } + this->status_clear_error(); + return true; +} + +void TAS2780::set_power_mode(uint8_t power_mode) { + // Lambda-supplied values bypass schema validation; refuse before anything touches the chip + if (power_mode >= 4) { + ESP_LOGE(TAG, "Invalid power mode %u, must be 0-3", power_mode); + return; + } + this->power_mode_ = power_mode; +} + +bool TAS2780::set_power_mode_(uint8_t power_mode) { + uint8_t cds_mode = (POWER_MODE_CDS_MODES >> (power_mode * 2)) & 0x03; + uint8_t vbat1s_mode = (POWER_MODE_VBAT1S_MODES >> power_mode) & 0x01; + return this->update_bits_(TAS2780_CHNL_0, TAS2780_CHNL_0_CDS_MODE_MASK, cds_mode << TAS2780_CHNL_0_CDS_MODE_SHIFT) && + this->update_bits_(TAS2780_DC_BLK0, TAS2780_DC_BLK0_VBAT1S_MODE_MASK, vbat1s_mode ? 0xFF : 0); +} + +void TAS2780::clear_latches_() { + this->update_bits_(TAS2780_INT_CLK_CFG, TAS2780_INT_CLK_CFG_CLR_LATCH, TAS2780_INT_CLK_CFG_CLR_LATCH); +} + +// Returns true if any latched interrupt flag is set +bool TAS2780::log_error_states_() { + uint8_t latched[4]; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_bytes(TAS2780_INT_LTCH0, latched, 3) || + !this->read_byte(TAS2780_INT_LTCH2, &latched[3])) { + return false; + } + // LDMODE is a two-bit field in INT_LTCH1; report it once + if (latched[1] & (1 << 4)) + latched[1] = (latched[1] & ~(1 << 4)) | (1 << 3); + for (uint8_t reg = 0; reg < 4; reg++) { + uint8_t errors = latched[reg] & (TAS2780_INT_LTCH_ERROR_MASKS >> (reg * 8)); + uint8_t infos = latched[reg] & (TAS2780_INT_LTCH_INFO_MASKS >> (reg * 8)); + for (uint8_t bit = 0; bit < 8; bit++) { + if (errors & (1 << bit)) { + ESP_LOGE(TAG, "%s", LOG_STR_ARG(fault_name(reg, bit))); + } else if (infos & (1 << bit)) { + ESP_LOGD(TAG, "%s", LOG_STR_ARG(fault_name(reg, bit))); + } + } + } + return (latched[0] | latched[1] | latched[2] | latched[3]) != 0; +} + +void TAS2780::update() { + // Latches hold until cleared; without this the same events are logged on every update + if (this->log_error_states_()) + this->clear_latches_(); +} + +void TAS2780::dump_config() { + ESP_LOGCONFIG(TAG, "Audio Amplifier:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " Power Mode: %u\n" + " Amp Level: %u\n" + " Volume Range: %.2f - %.2f\n" + " Channel: %s", + this->power_mode_, this->amp_level_, this->vol_range_min_, this->vol_range_max_, + this->selected_channel_ == LEFT_CHANNEL ? LOG_STR_LITERAL("Left") + : this->selected_channel_ == RIGHT_CHANNEL ? LOG_STR_LITERAL("Right") + : LOG_STR_LITERAL("Mono Downmix")); +} + +bool TAS2780::write_mode_ctrl_(uint8_t mode) { + return this->update_bits_(TAS2780_MODE_CTRL, TAS2780_MODE_CTRL_MODE_MASK, mode); +} + +uint8_t TAS2780::active_mode_() const { + return this->is_muted_ ? TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED : TAS2780_MODE_CTRL_MODE_ACTIVE; +} + +bool TAS2780::set_mute_(bool muted) { + bool previous = this->is_muted_; + this->is_muted_ = muted; + uint8_t mode_ctrl; + if (!this->select_page_(TAS2780_PAGE_0) || !this->read_byte(TAS2780_MODE_CTRL, &mode_ctrl)) { + ESP_LOGE(TAG, "Failed to read MODE_CTRL"); + this->is_muted_ = previous; + return false; + } + uint8_t current_mode = mode_ctrl & TAS2780_MODE_CTRL_MODE_MASK; + // Only switch between active/muted if device is active; don't wake from shutdown + if ((current_mode == TAS2780_MODE_CTRL_MODE_ACTIVE || current_mode == TAS2780_MODE_CTRL_MODE_ACTIVE_MUTED) && + !this->write_byte(TAS2780_MODE_CTRL, (mode_ctrl & ~TAS2780_MODE_CTRL_MODE_MASK) | this->active_mode_())) { + ESP_LOGE(TAG, "Failed to write MODE_CTRL"); + this->is_muted_ = previous; + return false; + } + return true; +} + +bool TAS2780::set_volume(float volume) { + float previous = this->volume_; + this->volume_ = clamp(volume, 0.0f, 1.0f); + if (!this->write_volume_()) { + this->volume_ = previous; + return false; + } + return true; +} + +bool TAS2780::write_volume_() { + // Lambda-supplied volume ranges are not bounded or ordered + float range_min = std::min(this->vol_range_min_, this->vol_range_max_); + float range_max = std::max(this->vol_range_min_, this->vol_range_max_); + float volume = clamp(std::lerp(range_min, range_max, this->volume_), 0.0f, 1.0f); + uint8_t dvc = remap(volume, 0.0f, 1.0f, TAS2780_DVC_MAX_ATTEN, 0); + ESP_LOGD(TAG, "Setting attenuation to: %u", dvc); + return this->select_page_(TAS2780_PAGE_0) && this->write_byte(TAS2780_DVC, dvc); +} + +bool TAS2780::apply_config() { return this->apply_amp_and_channel_config_() && this->write_volume_(); } + +bool TAS2780::apply_amp_and_channel_config_() { + // Lambda-supplied values bypass schema validation + if (this->amp_level_ > TAS2780_AMP_LEVEL_MAX) { + ESP_LOGW(TAG, "Amp level %u out of range, using %u", this->amp_level_, TAS2780_AMP_LEVEL_MAX); + this->amp_level_ = TAS2780_AMP_LEVEL_MAX; + } + ESP_LOGD(TAG, "Update amp to level idx: %u", this->amp_level_); + if (!this->update_bits_(TAS2780_CHNL_0, TAS2780_CHNL_0_AMP_LEVEL_MASK, + this->amp_level_ << TAS2780_CHNL_0_AMP_LEVEL_SHIFT)) { + return false; + } + return this->update_bits_( + TAS2780_TDM_CFG2, TAS2780_TDM_CFG2_RX_SCFG_MASK | TAS2780_TDM_CFG2_RX_WLEN_MASK | TAS2780_TDM_CFG2_RX_SLEN_MASK, + (this->selected_channel_ << TAS2780_TDM_CFG2_RX_SCFG_SHIFT) | TAS2780_TDM_CFG2_RX_WLEN_32BIT | + TAS2780_TDM_CFG2_RX_SLEN_32BIT); +} + +} // namespace esphome::tas2780 diff --git a/esphome/components/tas2780/tas2780.h b/esphome/components/tas2780/tas2780.h new file mode 100644 index 00000000000..443e2e849ed --- /dev/null +++ b/esphome/components/tas2780/tas2780.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +namespace esphome::tas2780 { + +// Values are the TDM_CFG2 RX_SCFG field. +enum ChannelSelect : uint8_t { LEFT_CHANNEL = 1, RIGHT_CHANNEL = 2, MONO_DWN_MIX = 3 }; + +class TAS2780 : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + void update() override; + + /// Software reset and re-initialization, leaving the amplifier on or off as last requested. + void reset(); + /// Activate with the configured power mode; re-initializes first when the mode changed. + void activate(); + void deactivate(); + /// Write the amp level, channel and volume range to the device. + bool apply_config(); + + bool set_mute_off() override { return this->set_mute_(false); } + bool set_mute_on() override { return this->set_mute_(true); } + bool set_volume(float volume) override; + + bool is_muted() override { return this->is_muted_; } + float volume() override { return this->volume_; } + + void set_amp_level(uint8_t amp_level) { this->amp_level_ = amp_level; } + void set_power_mode(uint8_t power_mode); + void set_vol_range_min(float min_val) { this->vol_range_min_ = min_val; } + void set_vol_range_max(float max_val) { this->vol_range_max_ = max_val; } + void set_selected_channel(ChannelSelect channel) { this->selected_channel_ = channel; } + + protected: + bool select_page_(uint8_t page); + bool update_bits_(uint8_t reg, uint8_t mask, uint8_t value); + bool init_(); + bool reinit_(); + bool set_power_mode_(uint8_t power_mode); + bool apply_amp_and_channel_config_(); + bool write_mode_ctrl_(uint8_t mode); + uint8_t active_mode_() const; + bool set_mute_(bool muted); + bool write_volume_(); + bool log_error_states_(); + void clear_latches_(); + + float volume_{0}; + float vol_range_min_{0.3f}; + float vol_range_max_{1.0f}; + uint8_t current_page_{0xFF}; + uint8_t power_mode_{2}; + uint8_t applied_power_mode_{0xFF}; + uint8_t amp_level_{8}; + ChannelSelect selected_channel_{MONO_DWN_MIX}; + bool active_{false}; +}; + +} // namespace esphome::tas2780 diff --git a/tests/components/tas2780/common.yaml b/tests/components/tas2780/common.yaml new file mode 100644 index 00000000000..5d825f8a8d0 --- /dev/null +++ b/tests/components/tas2780/common.yaml @@ -0,0 +1,31 @@ +audio_dac: + - platform: tas2780 + id: tas2780_amp + address: 0x38 + i2c_id: i2c_bus + amp_level: 8 + power_mode: 2 + vol_range_min: 0.3 + vol_range_max: 1.0 + channel: mono + +esphome: + on_boot: + - tas2780.activate: + id: tas2780_amp + - tas2780.activate: + id: tas2780_amp + power_mode: 1 + - tas2780.activate: + id: tas2780_amp + power_mode: !lambda "return id(tas2780_amp).volume() > 0.5f ? 3 : 1;" + - tas2780.update_config: + id: tas2780_amp + amp_level: 10 + vol_range_min: 0.2 + vol_range_max: 0.9 + channel: left + - tas2780.deactivate: + id: tas2780_amp + - tas2780.reset: + id: tas2780_amp diff --git a/tests/components/tas2780/test.esp32-idf.yaml b/tests/components/tas2780/test.esp32-idf.yaml new file mode 100644 index 00000000000..34984dd9372 --- /dev/null +++ b/tests/components/tas2780/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tas2780: !include common.yaml diff --git a/tests/components/tas2780/test.esp8266-ard.yaml b/tests/components/tas2780/test.esp8266-ard.yaml new file mode 100644 index 00000000000..39d8c2fda99 --- /dev/null +++ b/tests/components/tas2780/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tas2780: !include common.yaml diff --git a/tests/components/tas2780/test.rp2040-ard.yaml b/tests/components/tas2780/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fdc371097f9 --- /dev/null +++ b/tests/components/tas2780/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + tas2780: !include common.yaml From 04da1827e32b11fdd1d2f1ce5e6aaf60c9c011b2 Mon Sep 17 00:00:00 2001 From: David Friedland Date: Thu, 24 Sep 2026 07:48:46 -0700 Subject: [PATCH 443/759] [light] Add transition_state_publish_interval option to publish intermediate states during transitions (#11968) Co-authored-by: Cursor Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/light/__init__.py | 15 + .../components/light/light_json_schema.cpp | 2 +- esphome/components/light/light_state.cpp | 56 ++- esphome/components/light/light_state.h | 43 +++ esphome/components/light/transformers.h | 5 + .../prometheus/prometheus_handler.cpp | 2 +- esphome/components/tuya/light/tuya_light.cpp | 8 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/core/defines.h | 1 + tests/components/light/common.yaml | 1 + .../light_transition_interval_save.yaml | 38 ++ ...ght_transition_state_publish_interval.yaml | 121 +++++++ ...light_transition_state_publish_interval.py | 328 ++++++++++++++++++ 14 files changed, 606 insertions(+), 18 deletions(-) create mode 100644 tests/integration/fixtures/light_transition_interval_save.yaml create mode 100644 tests/integration/fixtures/light_transition_state_publish_interval.yaml create mode 100644 tests/integration/test_light_transition_state_publish_interval.py diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f69181b5274..3d685e22d98 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -600,7 +600,7 @@ bool APIConnection::send_light_state(light::LightState *light) { uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *light = static_cast(entity); LightStateResponse resp; - auto values = light->remote_values; + auto values = light->get_reported_values(); auto color_mode = values.get_color_mode(); resp.state = values.is_on(); resp.color_mode = static_cast(color_mode); diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index ab9624c3649..428d1abf1f4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -343,6 +343,7 @@ RESTORE_MODES = { # Schema default that also matches the C++ initializer in light_state.h; codegen # skips the setter when the config equals it. DEFAULT_FLASH_TRANSITION_LENGTH = "0s" +CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval" LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) @@ -393,6 +394,11 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( cv.Optional( CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, + # Below 150ms a device cannot publish any faster and only spends CPU and traffic + cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(milliseconds=150)), + ), cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } ) @@ -406,6 +412,11 @@ RGB_LIGHT_SCHEMA = BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(AddressableLightState), + # The addressable transformer writes the LED buffer directly, so there is no + # intermediate state to publish + cv.Optional(CONF_TRANSITION_STATE_PUBLISH_INTERVAL): cv.invalid( + "transition_state_publish_interval is not supported on addressable lights" + ), cv.Optional(CONF_EFFECTS): validate_effects(ADDRESSABLE_EFFECTS), cv.Optional(CONF_COLOR_CORRECT): cv.All( [cv.percentage], cv.Length(min=3, max=4) @@ -513,6 +524,10 @@ async def setup_light_core_(light_var, config, output_var): DEFAULT_FLASH_TRANSITION_LENGTH ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) + # Setting an interval opts this light in and compiles the feature in + if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None: + cg.add(light_var.set_transition_state_publish_interval(interval)) + cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL") if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) fwd_arr = _get_or_create_gamma_table(gamma_correct) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 8624fade16c..98df98ce5d6 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -32,7 +32,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { root[ESPHOME_F("effect_count")] = state.get_effect_count(); } - auto values = state.remote_values; + auto values = state.get_reported_values(); const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 82c00e23823..63c311f5a8d 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -1,6 +1,9 @@ #include "light_state.h" #include "esp_color_correction.h" #include "esphome/core/defines.h" +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +#include "esphome/core/application.h" +#endif #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" #include "light_output.h" @@ -105,6 +108,13 @@ void LightState::dump_config() { " Default Transition Length: %.1fs\n" " Gamma Correct: %.2f", this->default_transition_length_ / 1e3f, this->gamma_correct_); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + // The define is build wide; only lights that set the option have an interval + if (this->transition_state_publish_interval_ != 0) { + ESP_LOGCONFIG(TAG, " Transition State Publish Interval: %" PRIu32 "ms", + this->transition_state_publish_interval_); + } +#endif } if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) { ESP_LOGCONFIG(TAG, @@ -130,13 +140,30 @@ void LightState::loop() { this->next_write_ = true; } - if (this->transformer_->is_finished()) { + const bool finished = this->transformer_->is_finished(); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_ && !finished) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_transition_state_publish_ >= this->transition_state_publish_interval_) { + this->publish_state(); + this->last_transition_state_publish_ = now; + } + } +#endif + + if (finished) { // if the transition has written directly to the output, current_values is outdated, so update it this->current_values = this->transformer_->get_target_values(); - this->transformer_->stop(); this->is_transformer_active_ = false; this->transformer_ = nullptr; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_) { + // Report the end state from remote_values; a flash's stop() left publishing to us + this->transition_publish_enabled_ = false; + this->publish_state(); + } +#endif if (this->target_state_reached_listeners_) { for (auto *listener : *this->target_state_reached_listeners_) { listener->on_light_target_state_reached(); @@ -348,10 +375,7 @@ void LightState::stop_effect_() { void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) { this->transformer_ = this->output_->create_default_transition(); this->transformer_->setup(this->current_values, target, length); - - if (set_remote_values) { - this->remote_values = target; - } + this->set_transformer_remote_values_(target, set_remote_values); // Enable loop while transition is active this->enable_loop(); } @@ -365,10 +389,7 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b this->transformer_ = make_unique(*this); this->transformer_->setup(end_colors, target, length); - - if (set_remote_values) { - this->remote_values = target; - }; + this->set_transformer_remote_values_(target, set_remote_values); // Enable loop while flash is active this->enable_loop(); } @@ -376,6 +397,9 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) { this->is_transformer_active_ = false; this->transformer_ = nullptr; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + this->transition_publish_enabled_ = false; +#endif this->current_values = target; if (set_remote_values) { this->remote_values = target; @@ -391,6 +415,18 @@ void LightState::disable_loop_if_idle_() { } } +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +void LightState::set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) { + this->transition_publish_enabled_ = set_remote_values && this->transition_state_publish_interval_ > 0; + if (this->transition_publish_enabled_) { + this->last_transition_state_publish_ = App.get_loop_component_start_time(); + } + if (set_remote_values) { + this->remote_values = target; + } +} +#endif + void LightState::save_remote_values_() { LightStateRTCState saved; saved.color_mode = this->remote_values.get_color_mode(); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index eafa161f51e..22ce9bf198b 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -100,6 +100,21 @@ class LightState : public EntityBase, public Component { LightCall turn_on(); LightCall turn_off(); LightCall toggle(); + + /// The values reported to the frontend: current_values while a light publishes intermediate + /// states on an interval, otherwise remote_values. Each interval sample is a publish_state(), + /// so on_state automations run on every sample as well. + const LightColorValues &get_reported_values() const { +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + if (this->transition_publish_enabled_) { + return this->current_values; + } +#endif + return this->remote_values; + } + + /// True from the call that starts a transition or flash until it reaches its target. + bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); // ========== INTERNAL METHODS ========== @@ -172,6 +187,13 @@ class LightState : public EntityBase, public Component { void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } float get_gamma_correct() const { return this->gamma_correct_; } +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) { + this->transition_state_publish_interval_ = transition_state_publish_interval; + } + uint32_t get_transition_state_publish_interval() const { return this->transition_state_publish_interval_; } +#endif + #ifdef USE_LIGHT_GAMMA_LUT /// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array) void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; } @@ -291,6 +313,7 @@ class LightState : public EntityBase, public Component { friend LightOutput; friend LightCall; friend class AddressableLight; + friend class LightFlashTransformer; /// Internal method to start an effect with the given index void start_effect_(uint32_t effect_index); @@ -307,6 +330,18 @@ class LightState : public EntityBase, public Component { /// Internal method to set the color values to target immediately (with no transition). void set_immediately_(const LightColorValues &target, bool set_remote_values); + /// Point remote_values at the new transformer's target and, when this light publishes + /// intermediate states on an interval, start the interval clock. +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values); +#else + void set_transformer_remote_values_(const LightColorValues &target, bool set_remote_values) { + if (set_remote_values) { + this->remote_values = target; + } + } +#endif + /// Internal method to save the current remote_values to the preferences void save_remote_values_(); @@ -357,6 +392,10 @@ class LightState : public EntityBase, public Component { uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + uint32_t transition_state_publish_interval_{0}; + uint32_t last_transition_state_publish_{0}; +#endif /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT @@ -367,6 +406,10 @@ class LightState : public EntityBase, public Component { bool next_write_{true}; // for effects, true if a transformer (transition) is active. bool is_transformer_active_{false}; +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + /// True while the active transformer publishes current_values on an interval from loop(). + bool transition_publish_enabled_{false}; +#endif /// Restore mode of the light. LightRestoreMode restore_mode_; }; diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 34e192a0346..27f5c396dc8 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -105,6 +105,11 @@ class LightFlashTransformer : public LightTransformer { } this->state_.current_values = this->get_start_values(); this->state_.remote_values = this->get_start_values(); +#ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL + // The light reports the end state itself once the transformer finishes + if (this->state_.transition_publish_enabled_) + return; +#endif this->state_.publish_state(); } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 6e51707ccd3..00c2e043102 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -322,7 +322,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat // State print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\"} ")); - stream->print(obj->remote_values.is_on()); + stream->print(obj->get_reported_values().is_on()); stream->print(ESPHOME_F("\n")); // Brightness and RGBW light::LightColorValues color = obj->current_values; diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 9f3f3c13cce..32cbca9a390 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -9,7 +9,7 @@ static const char *const TAG = "tuya.light"; void TuyaLight::setup() { if (this->color_temperature_id_.has_value()) { this->parent_->register_listener(*this->color_temperature_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -27,7 +27,7 @@ void TuyaLight::setup() { } if (this->dimmer_id_.has_value()) { this->parent_->register_listener(*this->dimmer_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -39,7 +39,7 @@ void TuyaLight::setup() { } if (switch_id_.has_value()) { this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -51,7 +51,7 @@ void TuyaLight::setup() { } if (color_id_.has_value()) { this->parent_->register_listener(*this->color_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bad3d050f3a..49943f8a71b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1015,7 +1015,7 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_value(root, obj, "light", obj->remote_values.is_on() ? "ON" : "OFF", start_config); + set_json_value(root, obj, "light", obj->get_reported_values().is_on() ? "ON" : "OFF", start_config); light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 30c9dc27ede..7bc08ea5778 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_RADIO_FREQUENCY #define USE_LIGHT #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5dc7091e400..5d78c411e7a 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -187,6 +187,7 @@ light: name: Monochromatic Light output: test_ledc_1 gamma_correct: 2.8 + transition_state_publish_interval: 200ms default_transition_length: 2s effects: - strobe: diff --git a/tests/integration/fixtures/light_transition_interval_save.yaml b/tests/integration/fixtures/light_transition_interval_save.yaml new file mode 100644 index 00000000000..8f32b56e6d4 --- /dev/null +++ b/tests/integration/fixtures/light_transition_interval_save.yaml @@ -0,0 +1,38 @@ +esphome: + name: ltspi-save +host: +api: +logger: + +preferences: + flash_write_interval: 0s # Flush to disk on every loop + +output: + - platform: template + id: mono_output + type: float + write_action: + - logger.log: + format: "mono_output: %.2f" + args: [state] + +light: + - platform: monochromatic + id: test_mono_light + name: "Test Mono Light" + output: mono_output + restore_mode: RESTORE_DEFAULT_OFF + default_transition_length: 1s + transition_state_publish_interval: 0.2s + +button: + - platform: template + id: run_persistence_transition + name: "Run Persistence Transition" + on_press: + - lambda: |- + auto call = id(test_mono_light).turn_on(); + call.set_brightness(1.0f); + call.set_transition_length(1000); + call.set_save(true); + call.perform(); diff --git a/tests/integration/fixtures/light_transition_state_publish_interval.yaml b/tests/integration/fixtures/light_transition_state_publish_interval.yaml new file mode 100644 index 00000000000..85627c1738c --- /dev/null +++ b/tests/integration/fixtures/light_transition_state_publish_interval.yaml @@ -0,0 +1,121 @@ +esphome: + name: ltspi +host: +api: +logger: + +output: + # Monochromatic output + - platform: template + id: mono_output + type: float + write_action: + - logger.log: + format: "mono_output: %.2f" + args: [state] + # RGB outputs + - platform: template + id: rgb_red + type: float + write_action: + - logger.log: + format: "rgb_red: %.2f" + args: [state] + - platform: template + id: rgb_green + type: float + write_action: + - logger.log: + format: "rgb_green: %.2f" + args: [state] + - platform: template + id: rgb_blue + type: float + write_action: + - logger.log: + format: "rgb_blue: %.2f" + args: [state] + # CWWW outputs (using rgbww light for CT + cold/warm white coverage) + - platform: template + id: cwww_red + type: float + write_action: + - logger.log: + format: "cwww_red: %.2f" + args: [state] + - platform: template + id: cwww_green + type: float + write_action: + - logger.log: + format: "cwww_green: %.2f" + args: [state] + - platform: template + id: cwww_blue + type: float + write_action: + - logger.log: + format: "cwww_blue: %.2f" + args: [state] + - platform: template + id: cwww_cold_white + type: float + write_action: + - logger.log: + format: "cwww_cold_white: %.2f" + args: [state] + - platform: template + id: cwww_warm_white + type: float + write_action: + - logger.log: + format: "cwww_warm_white: %.2f" + args: [state] + - platform: template + id: legacy_output + type: float + write_action: + - logger.log: + format: "legacy_output: %.2f" + args: [state] + +light: + # Monochromatic light (brightness only) + - platform: monochromatic + id: test_mono_light + name: "Test Mono Light" + output: mono_output + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # RGB light + - platform: rgb + id: test_rgb_light + name: "Test RGB Light" + red: rgb_red + green: rgb_green + blue: rgb_blue + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # CWWW/CT-capable light + - platform: rgbww + id: test_cwww_light + name: "Test CWWW Light" + red: cwww_red + green: cwww_green + blue: cwww_blue + cold_white: cwww_cold_white + warm_white: cwww_warm_white + cold_white_color_temperature: 6500 K + warm_white_color_temperature: 2000 K + constant_brightness: true + default_transition_length: 1s + transition_state_publish_interval: 0.2s + + # No interval: publishes only when a call starts + - platform: monochromatic + id: test_legacy_light + name: "Test Legacy Light" + output: legacy_output + default_transition_length: 1s diff --git a/tests/integration/test_light_transition_state_publish_interval.py b/tests/integration/test_light_transition_state_publish_interval.py new file mode 100644 index 00000000000..3b37854e761 --- /dev/null +++ b/tests/integration/test_light_transition_state_publish_interval.py @@ -0,0 +1,328 @@ +"""Integration tests for the light transition_state_publish_interval option.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from itertools import pairwise + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + LightInfo, + LightState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity, wait_for_state +from .types import APIClientConnectedFactory, RunCompiledFunction + +Timeline = list[tuple[float, LightState]] +DonePredicate = Callable[[float, LightState], bool] + + +class _Recorder: + """Records the states one light publishes while an action runs. + + ``run`` fires ``action`` and returns the (elapsed, state) timeline once a + published state satisfies ``done``, after ``settle`` more seconds so late + publishes still land in the timeline. + """ + + def __init__(self) -> None: + self._loop = asyncio.get_running_loop() + self._event = asyncio.Event() + self._key = 0 + self._start = 0.0 + self._done: DonePredicate | None = None + self.timeline: Timeline = [] + + def on_state(self, state: EntityState) -> None: + if ( + self._done is None + or not isinstance(state, LightState) + or state.key != self._key + ): + return + elapsed = self._loop.time() - self._start + self.timeline.append((elapsed, state)) + if self._done(elapsed, state): + self._event.set() + + async def run( + self, + key: int, + action: Callable[[], None], + done: DonePredicate, + settle: float = 0.0, + ) -> Timeline: + self.timeline = [] + self._key = key + self._done = done + self._event.clear() + self._start = self._loop.time() + action() + async with asyncio.timeout(5): + await self._event.wait() + if settle: + await asyncio.sleep(settle) + self._done = None + return self.timeline + + +async def _subscribe(client: APIClient) -> tuple[list[EntityInfo], _Recorder]: + """List entities and attach a recorder once the initial states have arrived.""" + entities, _ = await client.list_entities_services() + helper = InitialStateHelper(entities) + recorder = _Recorder() + client.subscribe_states(helper.on_state_wrapper(recorder.on_state)) + await helper.wait_for_initial_states() + return entities, recorder + + +def _visible_brightness(state: LightState) -> float: + """Brightness as a remote sees it: an off light counts as zero.""" + return state.brightness if state.state else 0.0 + + +def _brightness_is(value: float) -> DonePredicate: + return lambda _elapsed, state: ( + _visible_brightness(state) == pytest.approx(value, abs=0.01) + ) + + +def _brightness_values(timeline: Timeline) -> list[float]: + return [_visible_brightness(state) for _, state in timeline] + + +def _assert_ramp(timeline: Timeline, target: float) -> None: + """Several states were published and the last one is ``target`` after ~1 s.""" + values = _brightness_values(timeline) + assert len(values) >= 3, values + assert values[-1] == pytest.approx(target, abs=0.05), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_light_without_interval_publishes_target_once( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A light without the option publishes the target once, up front.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + legacy = require_entity(entities, "test_legacy_light", LightInfo) + + timeline = await recorder.run( + legacy.key, + lambda: client.light_command( + key=legacy.key, state=True, brightness=0.8, transition_length=1.0 + ), + _brightness_is(0.8), + settle=1.3, + ) + + assert _brightness_values(timeline) == [pytest.approx(0.8)] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_transition_interval_nonzero_emits_intermediate_updates( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Turning on over 1 s with a 200 ms interval publishes a rising ramp.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + + timeline = await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, state=True, brightness=1.0, transition_length=1.0 + ), + _brightness_is(1.0), + ) + + values = _brightness_values(timeline) + assert len(values) >= 5, values + assert values[0] == pytest.approx(0.0, abs=0.1), values + assert values[-1] == pytest.approx(1.0, abs=0.05), values + assert len([v for v in values if 0.1 < v < 0.9]) >= 2, values + assert all(b >= a - 0.1 for a, b in pairwise(values)), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_light_transition_state_publish_interval( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Default-length, RGB and color temperature transitions publish on the interval.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + rgb = require_entity(entities, "test_rgb_light", LightInfo) + cwww = require_entity(entities, "test_cwww_light", LightInfo) + + # No transition_length: default_transition_length (1 s) applies + timeline = await recorder.run( + mono.key, + lambda: client.light_command(key=mono.key, state=True, brightness=1.0), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + timeline = await recorder.run( + rgb.key, + lambda: client.light_command( + key=rgb.key, + state=True, + brightness=1.0, + rgb=(1.0, 0.0, 0.0), + transition_length=1.0, + ), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + # Start at the cold end instantly so the fade to 300 mireds has a gradient + await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=153.0, + transition_length=0.0, + ), + lambda _t, s: ( + s.state and s.color_temperature == pytest.approx(153.0, abs=1.0) + ), + ) + timeline = await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=300.0, + transition_length=1.0, + ), + lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0), + ) + ct_values = [state.color_temperature for _, state in timeline] + assert len(ct_values) >= 3, ct_values + assert min(ct_values) < max(ct_values), ct_values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_flash_interval_emits_intermediate_updates( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A flash publishes its value on the interval and ends back where it started.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + + await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, state=True, brightness=0.4, transition_length=0.0 + ), + _brightness_is(0.4), + ) + timeline = await recorder.run( + mono.key, + lambda: client.light_command( + key=mono.key, brightness=1.0, flash_length=1.0 + ), + lambda t, s: ( + t > 0.5 and _visible_brightness(s) == pytest.approx(0.4, abs=0.01) + ), + ) + + values = _brightness_values(timeline) + assert values.count(pytest.approx(1.0, abs=0.01)) >= 3, values + assert values[-1] == pytest.approx(0.4, abs=0.05), values + assert timeline[-1][0] >= 0.8, timeline[-1][0] + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_interval_save") +async def test_transition_interval_persistence_semantics( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A save=true interval transition saves its target, not a sample, and restores it.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + mono = require_entity(entities, "test_mono_light", LightInfo) + button = require_entity(entities, "run_persistence_transition", ButtonInfo) + + timeline = await recorder.run( + mono.key, + lambda: client.button_command(button.key), + _brightness_is(1.0), + ) + _assert_ramp(timeline, 1.0) + + # The restored light fades up from off over its default transition, so wait for the end + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + mono = require_entity(entities, "test_mono_light", LightInfo) + await wait_for_state( + client, + lambda s: ( + isinstance(s, LightState) + and s.key == mono.key + and _visible_brightness(s) == pytest.approx(1.0, abs=0.01) + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("light_transition_state_publish_interval") +async def test_partial_call_during_interval_transition_keeps_target( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A colour temperature change mid-fade keeps the fade's brightness target.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, recorder = await _subscribe(client) + cwww = require_entity(entities, "test_cwww_light", LightInfo) + + # Let the fade from off run for a couple of samples before changing only the colour + await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, + state=True, + brightness=1.0, + color_temperature=153.0, + transition_length=1.0, + ), + lambda t, _s: t >= 0.3, + ) + timeline = await recorder.run( + cwww.key, + lambda: client.light_command( + key=cwww.key, color_temperature=300.0, transition_length=1.0 + ), + lambda _t, s: s.color_temperature == pytest.approx(300.0, abs=1.0), + ) + + assert _brightness_values(timeline)[-1] == pytest.approx(1.0, abs=0.01) From 6f71b9102b629c88b9979d901688f58a905aea6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:49:07 +0100 Subject: [PATCH 444/759] Bump click from 8.3.3 to 8.5.0 (#18925) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eacd7e0970c..c810755817f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ tzdata>=2026.4 # from time pyserial==3.5 platformio==6.2.0 esptool==5.4.0 -click==8.3.3 +click==8.5.0 aioesphomeapi==46.5.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 From 9f5ac44db17eb79e9f149e9c5a482febb59c7ddc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 15:52:37 +0100 Subject: [PATCH 445/759] [api] Default response only string fields to a null StringRef (#19025) --- esphome/components/api/api_pb2.h | 94 ++++++++-------- esphome/components/api/api_server.cpp | 5 +- esphome/core/string_ref.h | 30 +++++- script/api_protobuf/api_protobuf.py | 30 ++++-- tests/components/core/test_string_ref.cpp | 102 ++++++++++++++++++ .../api/test_api_protobuf_generator.py | 38 +++++++ 6 files changed, 240 insertions(+), 59 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b4c742fee98..aa30a020640 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -382,7 +382,7 @@ class InfoResponseProtoMessage : public ProtoMessage { StringRef name{}; bool disabled_by_default{false}; #ifdef USE_ENTITY_ICON - StringRef icon{}; + StringRef icon{nullptr, 0}; // null until set, encode only #endif enums::EntityCategory entity_category{}; #ifdef USE_DEVICES @@ -554,7 +554,7 @@ class DeviceInfo final : public ProtoMessage { #ifdef USE_SERIAL_PROXY class SerialProxyInfo final : public ProtoMessage { public: - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only enums::SerialProxyPortType port_type{}; uint32_t configured_line_states{0}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); @@ -749,7 +749,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool is_status_binary_sensor{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -796,7 +796,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_position{false}; bool supports_tilt{false}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool supports_stop{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -888,7 +888,7 @@ class FanStateResponse final : public StateResponseProtoMessage { bool oscillating{false}; enums::FanDirection direction{}; int32_t speed_level{0}; - StringRef preset_mode{}; + StringRef preset_mode{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -968,7 +968,7 @@ class LightStateResponse final : public StateResponseProtoMessage { float color_temperature{0.0f}; float cold_white{0.0f}; float warm_white{0.0f}; - StringRef effect{}; + StringRef effect{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1030,10 +1030,10 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_sensor_response"); } #endif - StringRef unit_of_measurement{}; + StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only int32_t accuracy_decimals{0}; bool force_update{false}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only enums::SensorStateClass state_class{}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1078,7 +1078,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); } #endif bool assumed_state{false}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1135,7 +1135,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1155,7 +1155,7 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_sensor_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1253,8 +1253,8 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_SERVICES class HomeassistantServiceMap final : public ProtoMessage { public: - StringRef key{}; - StringRef value{}; + StringRef key{nullptr, 0}; // null until set, encode only + StringRef value{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1274,7 +1274,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{}; + StringRef service{nullptr, 0}; // null until set, encode only FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; @@ -1286,7 +1286,7 @@ class HomeassistantActionRequest final : public ProtoMessage { bool wants_response{false}; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - StringRef response_template{}; + StringRef response_template{nullptr, 0}; // null until set, encode only #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1332,8 +1332,8 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_home_assistant_state_response"); } #endif - StringRef entity_id{}; - StringRef attribute{}; + StringRef entity_id{nullptr, 0}; // null until set, encode only + StringRef attribute{nullptr, 0}; // null until set, encode only bool once{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1426,13 +1426,13 @@ class GetTimeResponse final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only enums::ServiceArgType type{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{}; + StringRef description{nullptr, 0}; // null until set, encode only #endif #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef example{}; + StringRef example{nullptr, 0}; // null until set, encode only #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1453,12 +1453,12 @@ class ListEntitiesServicesResponse final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; #ifdef USE_API_USER_DEFINED_ACTION_METADATA - StringRef description{}; + StringRef description{nullptr, 0}; // null until set, encode only #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1525,7 +1525,7 @@ class ExecuteServiceResponse final : public ProtoMessage { #endif uint32_t call_id{0}; bool success{false}; - StringRef error_message{}; + StringRef error_message{nullptr, 0}; // null until set, encode only #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; @@ -1660,9 +1660,9 @@ class ClimateStateResponse final : public StateResponseProtoMessage { enums::ClimateAction action{}; enums::ClimateFanMode fan_mode{}; enums::ClimateSwingMode swing_mode{}; - StringRef custom_fan_mode{}; + StringRef custom_fan_mode{nullptr, 0}; // null until set, encode only enums::ClimatePreset preset{}; - StringRef custom_preset{}; + StringRef custom_preset{nullptr, 0}; // null until set, encode only float current_humidity{0.0f}; float target_humidity{0.0f}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); @@ -1795,9 +1795,9 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { float min_value{0.0f}; float max_value{0.0f}; float step{0.0f}; - StringRef unit_of_measurement{}; + StringRef unit_of_measurement{nullptr, 0}; // null until set, encode only enums::NumberMode mode{}; - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1875,7 +1875,7 @@ class SelectStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1982,7 +1982,7 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { bool assumed_state{false}; bool supports_open{false}; bool requires_code{false}; - StringRef code_format{}; + StringRef code_format{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -2041,7 +2041,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -2072,7 +2072,7 @@ class ButtonCommandRequest final : public CommandProtoMessage { #ifdef USE_MEDIA_PLAYER class MediaPlayerSupportedFormat final : public ProtoMessage { public: - StringRef format{}; + StringRef format{nullptr, 0}; // null until set, encode only uint32_t sample_rate{0}; uint32_t num_channels{0}; enums::MediaPlayerFormatPurpose purpose{}; @@ -2732,10 +2732,10 @@ class VoiceAssistantRequest final : public ProtoMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } #endif bool start{false}; - StringRef conversation_id{}; + StringRef conversation_id{nullptr, 0}; // null until set, encode only uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; - StringRef wake_word_phrase{}; + StringRef wake_word_phrase{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -2876,8 +2876,8 @@ class VoiceAssistantAnnounceFinished final : public ProtoMessage { }; class VoiceAssistantWakeWord final : public ProtoMessage { public: - StringRef id{}; - StringRef wake_word{}; + StringRef id{nullptr, 0}; // null until set, encode only + StringRef wake_word{nullptr, 0}; // null until set, encode only std::vector trained_languages{}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -3030,7 +3030,7 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { #endif uint32_t min_length{0}; uint32_t max_length{0}; - StringRef pattern{}; + StringRef pattern{nullptr, 0}; // null until set, encode only enums::TextMode mode{}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -3051,7 +3051,7 @@ class TextStateResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_state_response"); } #endif - StringRef state{}; + StringRef state{nullptr, 0}; // null until set, encode only bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -3211,7 +3211,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only const FixedVector *event_types{}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -3232,7 +3232,7 @@ class EventResponse final : public StateResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("event_response"); } #endif - StringRef event_type{}; + StringRef event_type{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -3254,7 +3254,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only bool assumed_state{false}; bool supports_position{false}; bool supports_stop{false}; @@ -3374,7 +3374,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } #endif - StringRef device_class{}; + StringRef device_class{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -3398,11 +3398,11 @@ class UpdateStateResponse final : public StateResponseProtoMessage { bool in_progress{false}; bool has_progress{false}; float progress{0.0f}; - StringRef current_version{}; - StringRef latest_version{}; - StringRef title{}; - StringRef release_summary{}; - StringRef release_url{}; + StringRef current_version{nullptr, 0}; // null until set, encode only + StringRef latest_version{nullptr, 0}; // null until set, encode only + StringRef title{nullptr, 0}; // null until set, encode only + StringRef release_summary{nullptr, 0}; // null until set, encode only + StringRef release_url{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -3740,7 +3740,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { uint32_t instance{0}; enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; - StringRef error_message{}; + StringRef error_message{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d74de98c75e..310cdcf67bb 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -455,8 +455,9 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // Home Assistant subscribes to actions shortly *after* authenticating, so actions // fired right at connection time (on_client_connected, on_time_sync, ...) can // arrive before the subscription and are lost - warn instead of failing silently. - ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", - call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), call.service.c_str(), + ESP_LOGW(TAG, "Home Assistant %s '%.*s' dropped; %s", + call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), + static_cast(call.service.size()), call.service.empty() ? "" : call.service.c_str(), this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") : LOG_STR_LITERAL("no client connected")); } diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 2c7ec914c7b..da071287048 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -22,6 +22,10 @@ namespace esphome { * pointer. When it is default constructed, it has empty string. You can freely copy or move around this struct, but * never free its pointer. str() function can be used to export the content as std::string. StringRef is adopted from * + * + * A StringRef may carry a null pointer while its length is zero (the generated api messages start their encode only + * string fields that way). Every member treats that as the empty string: the iterators form an empty range, and + * c_str() and byte() return the null pointer, so callers that print or copy through those must check empty() first. */ class StringRef { public: @@ -78,7 +82,7 @@ class StringRef { /// True if the view begins with the given prefix (std::string::starts_with-like) bool starts_with(const StringRef &prefix) const { - return len_ >= prefix.len_ && std::memcmp(base_, prefix.base_, prefix.len_) == 0; + return len_ >= prefix.len_ && (prefix.len_ == 0 || std::memcmp(base_, prefix.base_, prefix.len_) == 0); } bool starts_with(const char *prefix) const { return this->starts_with(StringRef(prefix)); } bool starts_with(const std::string &prefix) const { return this->starts_with(StringRef(prefix)); } @@ -92,14 +96,15 @@ class StringRef { return actual; } - std::string str() const { return std::string(base_, len_); } + std::string str() const { return std::string(base_, len_); } // fine for {nullptr, 0}: nothing is read const uint8_t *byte() const { return reinterpret_cast(base_); } operator std::string() const { return str(); } /// Compare (compatible with std::string::compare) int compare(const StringRef &other) const { - int result = std::memcmp(base_, other.base_, std::min(len_, other.len_)); + size_type common = std::min(len_, other.len_); + int result = common == 0 ? 0 : std::memcmp(base_, other.base_, common); if (result != 0) return result; if (len_ < other.len_) @@ -222,6 +227,11 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { namespace internal { // NOLINTBEGIN(google-runtime-int) template inline R parse_number(const StringRef &str, size_t *pos, F conv) { + if (str.empty()) { // nothing to parse, and a null view must not reach the C library + if (pos) + *pos = 0; + return R{}; + } char *end; R result = conv(str.c_str(), &end); // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number @@ -230,6 +240,11 @@ template inline R parse_number(const StringRef &str, siz return result; } template inline R parse_number(const StringRef &str, size_t *pos, int base, F conv) { + if (str.empty()) { // nothing to parse, and a null view must not reach the C library + if (pos) + *pos = 0; + return R{}; + } char *end; R result = conv(str.c_str(), &end, base); // Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number @@ -258,7 +273,14 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) -inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } +inline void convertToJson(const StringRef &src, JsonVariant dst) { + // Bounded by the view length; a null, empty view becomes "" rather than JSON null + if (src.empty()) { + dst.set(""); + return; + } + dst.set(JsonString(src.c_str(), src.size())); +} #endif // USE_JSON } // namespace esphome diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index e63e28096e1..bdfa58a3b3b 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -566,17 +566,17 @@ def create_field_type_info( # For messages that decode (SOURCE_CLIENT or SOURCE_BOTH), use pointer # for zero-copy access to the receive buffer if needs_decode: - return PointerToBytesBufferType(field, None) + return PointerToBytesBufferType(field, needs_decode) # For SOURCE_SERVER (encode only), explicit annotation is still needed if get_field_opt(field, pb.pointer_to_buffer, False): - return PointerToBytesBufferType(field, None) + return PointerToBytesBufferType(field, needs_decode) return BytesType(field, needs_decode, needs_encode) # Special handling for string fields - use StringRef for zero-copy if field.type == 9: - return PointerToStringBufferType(field, None) + return PointerToStringBufferType(field, needs_decode) validate_field_type(field.type, field.name) if field.type == 11: @@ -1134,11 +1134,12 @@ class PointerToBufferTypeBase(TypeInfo): def can_use_dump_field(cls) -> bool: return False + # Only here to make needs_decode required: the null string default keys off it, so a call + # site must not fall back on the base class default def __init__( - self, field: descriptor.FieldDescriptorProto, size: int | None = None + self, field: descriptor.FieldDescriptorProto, needs_decode: bool ) -> None: - super().__init__(field) - self.array_size = 0 + super().__init__(field, needs_decode) @property def wire_type(self) -> WireType: @@ -1219,14 +1220,28 @@ class PointerToStringBufferType(PointerToBufferTypeBase): def can_use_dump_field(cls) -> bool: return True + @property + def _starts_null(self) -> bool: + """A field that is only encoded, and skipped when empty, never has its pointer read + before it is set, so it can default to a null StringRef and the message constructs as + one zero fill. Any encode path that copies unconditionally must check this.""" + return not self._needs_decode and not self.force + @property def public_content(self) -> list[str]: + if self._starts_null: + return [ + f"StringRef {self.field_name}{{nullptr, 0}}; // null until set, encode only" + ] return [f"StringRef {self.field_name}{{}};"] @property def encode_content(self) -> str: max_len = self.max_data_length if max_len is not None and max_len < 128 and self.force: + assert not self._starts_null, ( + "unconditional copy of a field that may start null" + ) tag = self.calculate_tag() if tag < 128: return _encode_call( @@ -1236,6 +1251,9 @@ class PointerToStringBufferType(PointerToBufferTypeBase): f"this->{self.field_name}.c_str()", f"this->{self.field_name}.size()", ): + assert not self._starts_null, ( + "unconditional copy of a field that may start null" + ) return result return _encode_call( "encode_string", diff --git a/tests/components/core/test_string_ref.cpp b/tests/components/core/test_string_ref.cpp index bcbd0aa0d46..f66800dd178 100644 --- a/tests/components/core/test_string_ref.cpp +++ b/tests/components/core/test_string_ref.cpp @@ -2,6 +2,9 @@ #include "esphome/core/string_ref.h" +#include +#include + namespace esphome::core::testing { TEST(StringRefStartsWith, ProperPrefixMatches) { @@ -59,4 +62,103 @@ TEST(StringRefStartsWith, RefOverloadComparesOnlyTheViewedLength) { EXPECT_TRUE(ref.starts_with(prefix)); } +// The generated api messages start their encode only string fields as a null pointer with zero +// length; every member must treat that exactly like the default constructed empty string. +TEST(StringRefNullEmpty, BehavesAsEmptyString) { + const StringRef null_empty{nullptr, 0}; + const StringRef empty; + EXPECT_TRUE(null_empty.empty()); + EXPECT_EQ(null_empty.size(), 0u); + EXPECT_EQ(null_empty.c_str(), nullptr); + EXPECT_EQ(null_empty.byte(), nullptr); + EXPECT_TRUE(null_empty == empty); + EXPECT_FALSE(null_empty < empty); + EXPECT_FALSE(empty < null_empty); + EXPECT_TRUE(null_empty == ""); // NOLINT(readability-container-size-empty) - operator under test + EXPECT_TRUE(null_empty == std::string()); // NOLINT(readability-container-size-empty) - operator under test + EXPECT_EQ(null_empty.compare(empty), 0); + EXPECT_EQ(null_empty.compare(""), 0); + EXPECT_LT(null_empty.compare("a"), 0); + EXPECT_TRUE(null_empty.starts_with("")); + EXPECT_FALSE(null_empty.starts_with("a")); + EXPECT_EQ(null_empty.str(), std::string()); + EXPECT_EQ(null_empty.substr(0), std::string()); + EXPECT_EQ(null_empty.find('a'), std::string::npos); + EXPECT_EQ(null_empty.find("a"), std::string::npos); + char buf[4] = "xyz"; + EXPECT_EQ(null_empty.copy(buf, sizeof(buf)), 0u); + EXPECT_EQ(null_empty.begin(), null_empty.end()); +} + +TEST(StringRefNullEmpty, ComparesAgainstText) { + const StringRef null_empty{nullptr, 0}; + const StringRef text("abc", 3); + EXPECT_FALSE(null_empty == text); + EXPECT_FALSE(text == null_empty); + EXPECT_LT(null_empty.compare(text), 0); + EXPECT_GT(text.compare(null_empty), 0); + EXPECT_TRUE(null_empty < text); + EXPECT_FALSE(text < null_empty); + EXPECT_TRUE(text.starts_with(null_empty)); +} + +TEST(StringRefNullEmpty, TwoNullViewsAreEqual) { + const StringRef a{nullptr, 0}; + const StringRef b{nullptr, 0}; + EXPECT_TRUE(a == b); + EXPECT_FALSE(a < b); + EXPECT_EQ(a.compare(b), 0); + EXPECT_TRUE(a.starts_with(b)); +} + +// Every iterator endpoint of a null view is the same null position: nothing is dereferenced and +// no offset is applied to the null pointer, so the range is simply empty. +TEST(StringRefNullEmpty, IteratorEndpointsFormAnEmptyRange) { + const StringRef null_empty{nullptr, 0}; + EXPECT_EQ(null_empty.cbegin(), null_empty.cend()); + EXPECT_EQ(null_empty.rbegin(), null_empty.rend()); + EXPECT_EQ(null_empty.crbegin(), null_empty.crend()); + EXPECT_EQ(std::distance(null_empty.begin(), null_empty.end()), 0); + size_t visited = 0; + for (char c : null_empty) { + (void) c; + visited++; + } + EXPECT_EQ(visited, 0u); + // NOLINTNEXTLINE(bugprone-string-constructor) - empty range under test + EXPECT_EQ(std::string(null_empty.begin(), null_empty.end()), std::string()); +} + +// The pointer and length constructor accepts an empty range at a null pointer; the copy into a +// std::string reads nothing. +TEST(StringRefNullEmpty, ConvertsToEmptyStdString) { + const StringRef null_empty{nullptr, 0}; + const std::string copy = null_empty.str(); + EXPECT_TRUE(copy.empty()); + EXPECT_EQ(static_cast(null_empty), std::string()); + EXPECT_EQ(null_empty.substr(0, 5), std::string()); + std::string target("keep"); + target += null_empty; + EXPECT_EQ(target, "keep"); +} + +// The number conversions hand the pointer to the C library; a null view must stop before that. +TEST(StringRefNullEmpty, NumericConversionsReturnZero) { + const StringRef null_empty{nullptr, 0}; + size_t pos = 99; + EXPECT_EQ(stoi(null_empty, &pos), 0); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stol(null_empty, &pos, 16), 0L); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stof(null_empty, &pos), 0.0f); + EXPECT_EQ(pos, 0u); + pos = 99; + EXPECT_EQ(stod(null_empty, &pos), 0.0); + EXPECT_EQ(pos, 0u); + EXPECT_EQ(stoi(null_empty), 0); + EXPECT_EQ(stof(null_empty), 0.0f); +} + } // namespace esphome::core::testing diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 0e0a4f3fbf2..20a6f87c453 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -18,7 +18,9 @@ sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) import aioesphomeapi.api_options_pb2 as pb # noqa: E402 from api_protobuf import ( # noqa: E402 MAX_MESSAGE_ID, + SOURCE_BOTH, SOURCE_CLIENT, + SOURCE_SERVER, _make_ifdef_line, build_message_type, create_field_type_info, @@ -199,6 +201,24 @@ def _decode_case(field_type: int, number: int, *, repeated: bool = False) -> str ).decode_content +@pytest.mark.parametrize( + ("needs_decode", "force", "member"), + [ + (False, False, "StringRef value{nullptr, 0}; // null until set, encode only"), + (True, False, "StringRef value{};"), + (False, True, "StringRef value{};"), + ], +) +def test_string_fields_default_to_null_only_when_never_read( + needs_decode: bool, force: bool, member: str +) -> None: + """Only a string that is neither decoded nor force encoded may start as a null StringRef.""" + ti = create_field_type_info( + _field(STRING, force=force), needs_decode=needs_decode, needs_encode=True + ) + assert ti.public_content == [member] + + @pytest.mark.parametrize( ("field_type", "number", "wire_type", "accessor"), [ @@ -290,3 +310,21 @@ def test_message_gets_a_single_decode_field_override() -> None: (3, "WIRE_TYPE_FIXED32"), ): assert f"case proto_tag({number}, {wire_type}):" in cpp, cpp + + +@pytest.mark.parametrize("source", [SOURCE_CLIENT, SOURCE_SERVER, SOURCE_BOTH]) +def test_repeated_strings_generate_in_every_direction(source: int) -> None: + """A repeated string builds its element type from the type map, so the null + default plumbing on the pointer string type never sees it.""" + desc = descriptor_pb2.DescriptorProto(name="Names") + desc.field.add( + name="names", + number=1, + type=STRING, + label=descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED, + ) + header, cpp, _ = build_message_type(desc, {}, {"Names": source}) + assert "std::vector names{};" in header + assert "nullptr" not in header + if source != SOURCE_SERVER: + assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp From f8e8295088bf8bec6d3a3f8dddad14d11b8efb35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 16:14:25 +0100 Subject: [PATCH 446/759] [api] Decode without a vtable (#19029) --- esphome/components/api/api_pb2.cpp | 890 +++++++++++------- esphome/components/api/api_pb2.h | 294 ++++-- esphome/components/api/proto.cpp | 4 +- esphome/components/api/proto.h | 24 +- script/api_protobuf/api_protobuf.py | 61 +- .../api/test_api_protobuf_generator.py | 40 +- 6 files changed, 891 insertions(+), 422 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 5afce510746..9e4000b55bc 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,17 +7,18 @@ namespace esphome::api { -void HelloRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->client_info = StringRef(value.data(), value.size()); + msg.client_info = StringRef(value.data(), value.size()); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->api_version_major = value.as_varint(); + msg.api_version_major = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->api_version_minor = value.as_varint(); + msg.api_version_minor = value.as_varint(); break; } } @@ -39,11 +40,12 @@ uint32_t HelloResponse::calc_size_msg(const void *self) { size += 2 + msg.name.size(); return size; } -void DisconnectRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void DisconnectRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->reason = static_cast(value.as_varint()); + msg.reason = static_cast(value.as_varint()); break; } } @@ -459,30 +461,31 @@ uint32_t CoverStateResponse::calc_size_msg(const void *self) { #endif return size; } -void CoverCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->has_position = value.as_bool(); + msg.has_position = value.as_bool(); break; case proto_tag(5, WIRE_TYPE_FIXED32): - this->position = value.as_float(); + msg.position = value.as_float(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_tilt = value.as_bool(); + msg.has_tilt = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_FIXED32): - this->tilt = value.as_float(); + msg.tilt = value.as_float(); break; case proto_tag(8, WIRE_TYPE_VARINT): - this->stop = value.as_bool(); + msg.stop = value.as_bool(); break; #ifdef USE_DEVICES case proto_tag(9, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -565,45 +568,46 @@ uint32_t FanStateResponse::calc_size_msg(const void *self) { #endif return size; } -void FanCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void FanCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_state = value.as_bool(); + msg.has_state = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->state = value.as_bool(); + msg.state = value.as_bool(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_oscillating = value.as_bool(); + msg.has_oscillating = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_VARINT): - this->oscillating = value.as_bool(); + msg.oscillating = value.as_bool(); break; case proto_tag(8, WIRE_TYPE_VARINT): - this->has_direction = value.as_bool(); + msg.has_direction = value.as_bool(); break; case proto_tag(9, WIRE_TYPE_VARINT): - this->direction = static_cast(value.as_varint()); + msg.direction = static_cast(value.as_varint()); break; case proto_tag(10, WIRE_TYPE_VARINT): - this->has_speed_level = value.as_bool(); + msg.has_speed_level = value.as_bool(); break; case proto_tag(11, WIRE_TYPE_VARINT): - this->speed_level = static_cast(value.as_varint()); + msg.speed_level = static_cast(value.as_varint()); break; case proto_tag(12, WIRE_TYPE_VARINT): - this->has_preset_mode = value.as_bool(); + msg.has_preset_mode = value.as_bool(); break; case proto_tag(13, WIRE_TYPE_LENGTH_DELIMITED): - this->preset_mode = StringRef(value.data(), value.size()); + msg.preset_mode = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(14, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -724,93 +728,94 @@ uint32_t LightStateResponse::calc_size_msg(const void *self) { #endif return size; } -void LightCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void LightCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_state = value.as_bool(); + msg.has_state = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->state = value.as_bool(); + msg.state = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->has_brightness = value.as_bool(); + msg.has_brightness = value.as_bool(); break; case proto_tag(5, WIRE_TYPE_FIXED32): - this->brightness = value.as_float(); + msg.brightness = value.as_float(); break; case proto_tag(22, WIRE_TYPE_VARINT): - this->has_color_mode = value.as_bool(); + msg.has_color_mode = value.as_bool(); break; case proto_tag(23, WIRE_TYPE_VARINT): - this->color_mode = static_cast(value.as_varint()); + msg.color_mode = static_cast(value.as_varint()); break; case proto_tag(20, WIRE_TYPE_VARINT): - this->has_color_brightness = value.as_bool(); + msg.has_color_brightness = value.as_bool(); break; case proto_tag(21, WIRE_TYPE_FIXED32): - this->color_brightness = value.as_float(); + msg.color_brightness = value.as_float(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_rgb = value.as_bool(); + msg.has_rgb = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_FIXED32): - this->red = value.as_float(); + msg.red = value.as_float(); break; case proto_tag(8, WIRE_TYPE_FIXED32): - this->green = value.as_float(); + msg.green = value.as_float(); break; case proto_tag(9, WIRE_TYPE_FIXED32): - this->blue = value.as_float(); + msg.blue = value.as_float(); break; case proto_tag(10, WIRE_TYPE_VARINT): - this->has_white = value.as_bool(); + msg.has_white = value.as_bool(); break; case proto_tag(11, WIRE_TYPE_FIXED32): - this->white = value.as_float(); + msg.white = value.as_float(); break; case proto_tag(12, WIRE_TYPE_VARINT): - this->has_color_temperature = value.as_bool(); + msg.has_color_temperature = value.as_bool(); break; case proto_tag(13, WIRE_TYPE_FIXED32): - this->color_temperature = value.as_float(); + msg.color_temperature = value.as_float(); break; case proto_tag(24, WIRE_TYPE_VARINT): - this->has_cold_white = value.as_bool(); + msg.has_cold_white = value.as_bool(); break; case proto_tag(25, WIRE_TYPE_FIXED32): - this->cold_white = value.as_float(); + msg.cold_white = value.as_float(); break; case proto_tag(26, WIRE_TYPE_VARINT): - this->has_warm_white = value.as_bool(); + msg.has_warm_white = value.as_bool(); break; case proto_tag(27, WIRE_TYPE_FIXED32): - this->warm_white = value.as_float(); + msg.warm_white = value.as_float(); break; case proto_tag(14, WIRE_TYPE_VARINT): - this->has_transition_length = value.as_bool(); + msg.has_transition_length = value.as_bool(); break; case proto_tag(15, WIRE_TYPE_VARINT): - this->transition_length = value.as_varint(); + msg.transition_length = value.as_varint(); break; case proto_tag(16, WIRE_TYPE_VARINT): - this->has_flash_length = value.as_bool(); + msg.has_flash_length = value.as_bool(); break; case proto_tag(17, WIRE_TYPE_VARINT): - this->flash_length = value.as_varint(); + msg.flash_length = value.as_varint(); break; case proto_tag(18, WIRE_TYPE_VARINT): - this->has_effect = value.as_bool(); + msg.has_effect = value.as_bool(); break; case proto_tag(19, WIRE_TYPE_LENGTH_DELIMITED): - this->effect = StringRef(value.data(), value.size()); + msg.effect = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(28, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -945,18 +950,19 @@ uint32_t SwitchStateResponse::calc_size_msg(const void *self) { #endif return size; } -void SwitchCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->state = value.as_bool(); + msg.state = value.as_bool(); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -1021,14 +1027,15 @@ uint32_t TextSensorStateResponse::calc_size_msg(const void *self) { return size; } #endif -void SubscribeLogsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SubscribeLogsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->level = static_cast(value.as_varint()); + msg.level = static_cast(value.as_varint()); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->dump_config = value.as_bool(); + msg.dump_config = value.as_bool(); break; } } @@ -1053,12 +1060,14 @@ SubscribeLogsResponse::calc_size_msg(const void *self) { return size; } #ifdef USE_API_NOISE -void NoiseEncryptionSetKeyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void NoiseEncryptionSetKeyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->key = value.data(); - this->key_len = value.size(); + msg.key = value.data(); + msg.key_len = value.size(); break; } } @@ -1149,22 +1158,24 @@ uint32_t HomeassistantActionRequest::calc_size_msg(const void *self) { } #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -void HomeassistantActionResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void HomeassistantActionResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->call_id = value.as_varint(); + msg.call_id = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->success = value.as_bool(); + msg.success = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->error_message = StringRef(value.data(), value.size()); + msg.error_message = StringRef(value.data(), value.size()); break; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - this->response_data = value.data(); - this->response_data_len = value.size(); + msg.response_data = value.data(); + msg.response_data_len = value.size(); break; #endif } @@ -1188,70 +1199,75 @@ uint32_t SubscribeHomeAssistantStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_bool(1, msg.once); return size; } -void HomeAssistantStateResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void HomeAssistantStateResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->entity_id = StringRef(value.data(), value.size()); + msg.entity_id = StringRef(value.data(), value.size()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->state = StringRef(value.data(), value.size()); + msg.state = StringRef(value.data(), value.size()); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->attribute = StringRef(value.data(), value.size()); + msg.attribute = StringRef(value.data(), value.size()); break; } } #endif -void DSTRule::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void DSTRule::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->time_seconds = decode_zigzag32(static_cast(value.as_varint())); + msg.time_seconds = decode_zigzag32(static_cast(value.as_varint())); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->day = value.as_varint(); + msg.day = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->type = static_cast(value.as_varint()); + msg.type = static_cast(value.as_varint()); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->month = value.as_varint(); + msg.month = value.as_varint(); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->week = value.as_varint(); + msg.week = value.as_varint(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->day_of_week = value.as_varint(); + msg.day_of_week = value.as_varint(); break; } } -void ParsedTimezone::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ParsedTimezone::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->std_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); + msg.std_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->dst_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); + msg.dst_offset_seconds = decode_zigzag32(static_cast(value.as_varint())); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - value.decode_to_message(this->dst_start); + value.decode_to_message(msg.dst_start); break; case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - value.decode_to_message(this->dst_end); + value.decode_to_message(msg.dst_end); break; } } -void GetTimeResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void GetTimeResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->epoch_seconds = value.as_fixed32(); + msg.epoch_seconds = value.as_fixed32(); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - value.decode_to_message(this->parsed_timezone); - this->has_parsed_timezone = true; + value.decode_to_message(msg.parsed_timezone); + msg.has_parsed_timezone = true; break; } } @@ -1312,35 +1328,36 @@ uint32_t ListEntitiesServicesResponse::calc_size_msg(const void *self) { #endif return size; } -void ExecuteServiceArgument::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ExecuteServiceArgument::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->bool_ = value.as_bool(); + msg.bool_ = value.as_bool(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->legacy_int = static_cast(value.as_varint()); + msg.legacy_int = static_cast(value.as_varint()); break; case proto_tag(3, WIRE_TYPE_FIXED32): - this->float_ = value.as_float(); + msg.float_ = value.as_float(); break; case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - this->string_ = StringRef(value.data(), value.size()); + msg.string_ = StringRef(value.data(), value.size()); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->int_ = decode_zigzag32(static_cast(value.as_varint())); + msg.int_ = decode_zigzag32(static_cast(value.as_varint())); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->bool_array.push_back(value.as_bool()); + msg.bool_array.push_back(value.as_bool()); break; case proto_tag(7, WIRE_TYPE_VARINT): - this->int_array.push_back(decode_zigzag32(static_cast(value.as_varint()))); + msg.int_array.push_back(decode_zigzag32(static_cast(value.as_varint()))); break; case proto_tag(8, WIRE_TYPE_FIXED32): - this->float_array.push_back(value.as_float()); + msg.float_array.push_back(value.as_float()); break; case proto_tag(9, WIRE_TYPE_LENGTH_DELIMITED): - this->string_array.push_back(value.as_string()); + msg.string_array.push_back(value.as_string()); break; } } @@ -1353,26 +1370,27 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->float_array.init(count_float_array); uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9); this->string_array.init(count_string_array); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } -void ExecuteServiceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ExecuteServiceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->args.emplace_back(); - value.decode_to_message(this->args.back()); + msg.args.emplace_back(); + value.decode_to_message(msg.args.back()); break; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case proto_tag(3, WIRE_TYPE_VARINT): - this->call_id = value.as_varint(); + msg.call_id = value.as_varint(); break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case proto_tag(4, WIRE_TYPE_VARINT): - this->return_response = value.as_bool(); + msg.return_response = value.as_bool(); break; #endif } @@ -1380,7 +1398,7 @@ void ExecuteServiceRequest::decode_field(uint32_t tag, const uint8_t *data, prot void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2); this->args.init(count_args); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1462,14 +1480,15 @@ uint32_t CameraImageResponse::calc_size_msg(const void *self) { #endif return size; } -void CameraImageRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void CameraImageRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->single = value.as_bool(); + msg.single = value.as_bool(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->stream = value.as_bool(); + msg.stream = value.as_bool(); break; } } @@ -1635,75 +1654,76 @@ uint32_t ClimateStateResponse::calc_size_msg(const void *self) { #endif return size; } -void ClimateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_mode = value.as_bool(); + msg.has_mode = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->mode = static_cast(value.as_varint()); + msg.mode = static_cast(value.as_varint()); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->has_target_temperature = value.as_bool(); + msg.has_target_temperature = value.as_bool(); break; case proto_tag(5, WIRE_TYPE_FIXED32): - this->target_temperature = value.as_float(); + msg.target_temperature = value.as_float(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_target_temperature_low = value.as_bool(); + msg.has_target_temperature_low = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_FIXED32): - this->target_temperature_low = value.as_float(); + msg.target_temperature_low = value.as_float(); break; case proto_tag(8, WIRE_TYPE_VARINT): - this->has_target_temperature_high = value.as_bool(); + msg.has_target_temperature_high = value.as_bool(); break; case proto_tag(9, WIRE_TYPE_FIXED32): - this->target_temperature_high = value.as_float(); + msg.target_temperature_high = value.as_float(); break; case proto_tag(12, WIRE_TYPE_VARINT): - this->has_fan_mode = value.as_bool(); + msg.has_fan_mode = value.as_bool(); break; case proto_tag(13, WIRE_TYPE_VARINT): - this->fan_mode = static_cast(value.as_varint()); + msg.fan_mode = static_cast(value.as_varint()); break; case proto_tag(14, WIRE_TYPE_VARINT): - this->has_swing_mode = value.as_bool(); + msg.has_swing_mode = value.as_bool(); break; case proto_tag(15, WIRE_TYPE_VARINT): - this->swing_mode = static_cast(value.as_varint()); + msg.swing_mode = static_cast(value.as_varint()); break; case proto_tag(16, WIRE_TYPE_VARINT): - this->has_custom_fan_mode = value.as_bool(); + msg.has_custom_fan_mode = value.as_bool(); break; case proto_tag(17, WIRE_TYPE_LENGTH_DELIMITED): - this->custom_fan_mode = StringRef(value.data(), value.size()); + msg.custom_fan_mode = StringRef(value.data(), value.size()); break; case proto_tag(18, WIRE_TYPE_VARINT): - this->has_preset = value.as_bool(); + msg.has_preset = value.as_bool(); break; case proto_tag(19, WIRE_TYPE_VARINT): - this->preset = static_cast(value.as_varint()); + msg.preset = static_cast(value.as_varint()); break; case proto_tag(20, WIRE_TYPE_VARINT): - this->has_custom_preset = value.as_bool(); + msg.has_custom_preset = value.as_bool(); break; case proto_tag(21, WIRE_TYPE_LENGTH_DELIMITED): - this->custom_preset = StringRef(value.data(), value.size()); + msg.custom_preset = StringRef(value.data(), value.size()); break; case proto_tag(22, WIRE_TYPE_VARINT): - this->has_target_humidity = value.as_bool(); + msg.has_target_humidity = value.as_bool(); break; case proto_tag(23, WIRE_TYPE_FIXED32): - this->target_humidity = value.as_float(); + msg.target_humidity = value.as_float(); break; #ifdef USE_DEVICES case proto_tag(24, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -1803,34 +1823,36 @@ uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_float(1, msg.target_temperature_high); return size; } -void WaterHeaterCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_fields = value.as_varint(); + msg.has_fields = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->mode = static_cast(value.as_varint()); + msg.mode = static_cast(value.as_varint()); break; case proto_tag(4, WIRE_TYPE_FIXED32): - this->target_temperature = value.as_float(); + msg.target_temperature = value.as_float(); break; #ifdef USE_DEVICES case proto_tag(5, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif case proto_tag(6, WIRE_TYPE_VARINT): - this->state = value.as_varint(); + msg.state = value.as_varint(); break; case proto_tag(7, WIRE_TYPE_FIXED32): - this->target_temperature_low = value.as_float(); + msg.target_temperature_low = value.as_float(); break; case proto_tag(8, WIRE_TYPE_FIXED32): - this->target_temperature_high = value.as_float(); + msg.target_temperature_high = value.as_float(); break; } } @@ -1910,18 +1932,19 @@ uint32_t NumberStateResponse::calc_size_msg(const void *self) { #endif return size; } -void NumberCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void NumberCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_FIXED32): - this->state = value.as_float(); + msg.state = value.as_float(); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -1990,18 +2013,19 @@ uint32_t SelectStateResponse::calc_size_msg(const void *self) { #endif return size; } -void SelectCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SelectCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->state = StringRef(value.data(), value.size()); + msg.state = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -2072,39 +2096,40 @@ uint32_t SirenStateResponse::calc_size_msg(const void *self) { #endif return size; } -void SirenCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SirenCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_state = value.as_bool(); + msg.has_state = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->state = value.as_bool(); + msg.state = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->has_tone = value.as_bool(); + msg.has_tone = value.as_bool(); break; case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): - this->tone = StringRef(value.data(), value.size()); + msg.tone = StringRef(value.data(), value.size()); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_duration = value.as_bool(); + msg.has_duration = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_VARINT): - this->duration = value.as_varint(); + msg.duration = value.as_varint(); break; case proto_tag(8, WIRE_TYPE_VARINT): - this->has_volume = value.as_bool(); + msg.has_volume = value.as_bool(); break; case proto_tag(9, WIRE_TYPE_FIXED32): - this->volume = value.as_float(); + msg.volume = value.as_float(); break; #ifdef USE_DEVICES case proto_tag(10, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -2171,24 +2196,25 @@ uint32_t LockStateResponse::calc_size_msg(const void *self) { #endif return size; } -void LockCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void LockCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->command = static_cast(value.as_varint()); + msg.command = static_cast(value.as_varint()); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->has_code = value.as_bool(); + msg.has_code = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - this->code = StringRef(value.data(), value.size()); + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(5, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -2229,15 +2255,16 @@ uint32_t ListEntitiesButtonResponse::calc_size_msg(const void *self) { #endif return size; } -void ButtonCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ButtonCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; #ifdef USE_DEVICES case proto_tag(2, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -2333,51 +2360,54 @@ uint32_t MediaPlayerStateResponse::calc_size_msg(const void *self) { #endif return size; } -void MediaPlayerCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void MediaPlayerCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_command = value.as_bool(); + msg.has_command = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->command = static_cast(value.as_varint()); + msg.command = static_cast(value.as_varint()); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->has_volume = value.as_bool(); + msg.has_volume = value.as_bool(); break; case proto_tag(5, WIRE_TYPE_FIXED32): - this->volume = value.as_float(); + msg.volume = value.as_float(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->has_media_url = value.as_bool(); + msg.has_media_url = value.as_bool(); break; case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): - this->media_url = StringRef(value.data(), value.size()); + msg.media_url = StringRef(value.data(), value.size()); break; case proto_tag(8, WIRE_TYPE_VARINT): - this->has_announcement = value.as_bool(); + msg.has_announcement = value.as_bool(); break; case proto_tag(9, WIRE_TYPE_VARINT): - this->announcement = value.as_bool(); + msg.announcement = value.as_bool(); break; #ifdef USE_DEVICES case proto_tag(10, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } } #endif #ifdef USE_BLUETOOTH_PROXY -void SubscribeBluetoothLEAdvertisementsRequest::decode_field(uint32_t tag, const uint8_t *data, +void SubscribeBluetoothLEAdvertisementsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->flags = value.as_varint(); + msg.flags = value.as_varint(); break; } } @@ -2423,20 +2453,21 @@ BluetoothLERawAdvertisementsResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -void BluetoothDeviceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothDeviceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->request_type = static_cast(value.as_varint()); + msg.request_type = static_cast(value.as_varint()); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->has_address_type = value.as_bool(); + msg.has_address_type = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->address_type = value.as_varint(); + msg.address_type = value.as_varint(); break; } } @@ -2459,11 +2490,13 @@ uint32_t BluetoothDeviceConnectionResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_int32(1, msg.error); return size; } -void BluetoothGATTGetServicesRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTGetServicesRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; } } @@ -2585,14 +2618,16 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint64(1, msg.address); return size; } -void BluetoothGATTReadRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTReadRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->handle = value.as_varint(); + msg.handle = value.as_varint(); break; } } @@ -2612,61 +2647,69 @@ uint32_t BluetoothGATTReadResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len_); return size; } -void BluetoothGATTWriteRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->handle = value.as_varint(); + msg.handle = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->response = value.as_bool(); + msg.response = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; } } -void BluetoothGATTReadDescriptorRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTReadDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->handle = value.as_varint(); + msg.handle = value.as_varint(); break; } } -void BluetoothGATTWriteDescriptorRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTWriteDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->handle = value.as_varint(); + msg.handle = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; } } -void BluetoothGATTNotifyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->handle = value.as_varint(); + msg.handle = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->enable = value.as_bool(); + msg.enable = value.as_bool(); break; } } @@ -2826,24 +2869,28 @@ uint32_t BluetoothScannerStateResponse::calc_size_msg(const void *self) { size += msg.configured_mode ? 2 : 0; return size; } -void BluetoothScannerSetModeRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothScannerSetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->mode = static_cast(value.as_varint()); + msg.mode = static_cast(value.as_varint()); break; } } #endif #ifdef USE_VOICE_ASSISTANT -void SubscribeVoiceAssistantRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SubscribeVoiceAssistantRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->subscribe = value.as_bool(); + msg.subscribe = value.as_bool(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->flags = value.as_varint(); + msg.flags = value.as_varint(); break; } } @@ -2885,53 +2932,58 @@ uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); return size; } -void VoiceAssistantResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->port = value.as_varint(); + msg.port = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->error = value.as_bool(); + msg.error = value.as_bool(); break; } } -void VoiceAssistantEventData::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantEventData::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->name = StringRef(value.data(), value.size()); + msg.name = StringRef(value.data(), value.size()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->value = StringRef(value.data(), value.size()); + msg.value = StringRef(value.data(), value.size()); break; } } -void VoiceAssistantEventResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->event_type = static_cast(value.as_varint()); + msg.event_type = static_cast(value.as_varint()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->data.emplace_back(); - value.decode_to_message(this->data.back()); + msg.data.emplace_back(); + value.decode_to_message(msg.data.back()); break; } } -void VoiceAssistantAudio::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->end = value.as_bool(); + msg.end = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->data2 = value.data(); - this->data2_len = value.size(); + msg.data2 = value.data(); + msg.data2_len = value.size(); break; } } @@ -2951,43 +3003,47 @@ uint32_t VoiceAssistantAudio::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data2_len); return size; } -void VoiceAssistantTimerEventResponse::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantTimerEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->event_type = static_cast(value.as_varint()); + msg.event_type = static_cast(value.as_varint()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->timer_id = StringRef(value.data(), value.size()); + msg.timer_id = StringRef(value.data(), value.size()); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->name = StringRef(value.data(), value.size()); + msg.name = StringRef(value.data(), value.size()); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->total_seconds = value.as_varint(); + msg.total_seconds = value.as_varint(); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->seconds_left = value.as_varint(); + msg.seconds_left = value.as_varint(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->is_active = value.as_bool(); + msg.is_active = value.as_bool(); break; } } -void VoiceAssistantAnnounceRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->media_id = StringRef(value.data(), value.size()); + msg.media_id = StringRef(value.data(), value.size()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->text = StringRef(value.data(), value.size()); + msg.text = StringRef(value.data(), value.size()); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->preannounce_media_id = StringRef(value.data(), value.size()); + msg.preannounce_media_id = StringRef(value.data(), value.size()); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->start_conversation = value.as_bool(); + msg.start_conversation = value.as_bool(); break; } } @@ -3026,38 +3082,42 @@ uint32_t VoiceAssistantWakeWord::calc_size_msg(const void *self) { } return size; } -void VoiceAssistantExternalWakeWord::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantExternalWakeWord::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->id = StringRef(value.data(), value.size()); + msg.id = StringRef(value.data(), value.size()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->wake_word = StringRef(value.data(), value.size()); + msg.wake_word = StringRef(value.data(), value.size()); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->trained_languages.push_back(value.as_string()); + msg.trained_languages.push_back(value.as_string()); break; case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): - this->model_type = StringRef(value.data(), value.size()); + msg.model_type = StringRef(value.data(), value.size()); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->model_size = value.as_varint(); + msg.model_size = value.as_varint(); break; case proto_tag(6, WIRE_TYPE_LENGTH_DELIMITED): - this->model_hash = StringRef(value.data(), value.size()); + msg.model_hash = StringRef(value.data(), value.size()); break; case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): - this->url = StringRef(value.data(), value.size()); + msg.url = StringRef(value.data(), value.size()); break; } } -void VoiceAssistantConfigurationRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); + msg.external_wake_words.emplace_back(); + value.decode_to_message(msg.external_wake_words.back()); break; } } @@ -3090,11 +3150,13 @@ uint32_t VoiceAssistantConfigurationResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.max_active_wake_words); return size; } -void VoiceAssistantSetConfiguration::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void VoiceAssistantSetConfiguration::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->active_wake_words.push_back(value.as_string()); + msg.active_wake_words.push_back(value.as_string()); break; } } @@ -3160,21 +3222,23 @@ uint32_t AlarmControlPanelStateResponse::calc_size_msg(const void *self) { #endif return size; } -void AlarmControlPanelCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void AlarmControlPanelCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->command = static_cast(value.as_varint()); + msg.command = static_cast(value.as_varint()); break; case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): - this->code = StringRef(value.data(), value.size()); + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(4, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3243,18 +3307,19 @@ uint32_t TextStateResponse::calc_size_msg(const void *self) { #endif return size; } -void TextCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void TextCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->state = StringRef(value.data(), value.size()); + msg.state = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3319,24 +3384,25 @@ uint32_t DateStateResponse::calc_size_msg(const void *self) { #endif return size; } -void DateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void DateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->year = value.as_varint(); + msg.year = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->month = value.as_varint(); + msg.month = value.as_varint(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->day = value.as_varint(); + msg.day = value.as_varint(); break; #ifdef USE_DEVICES case proto_tag(5, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3401,24 +3467,25 @@ uint32_t TimeStateResponse::calc_size_msg(const void *self) { #endif return size; } -void TimeCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void TimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->hour = value.as_varint(); + msg.hour = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->minute = value.as_varint(); + msg.minute = value.as_varint(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->second = value.as_varint(); + msg.second = value.as_varint(); break; #ifdef USE_DEVICES case proto_tag(5, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3553,24 +3620,25 @@ uint32_t ValveStateResponse::calc_size_msg(const void *self) { #endif return size; } -void ValveCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ValveCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->has_position = value.as_bool(); + msg.has_position = value.as_bool(); break; case proto_tag(3, WIRE_TYPE_FIXED32): - this->position = value.as_float(); + msg.position = value.as_float(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->stop = value.as_bool(); + msg.stop = value.as_bool(); break; #ifdef USE_DEVICES case proto_tag(5, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3633,18 +3701,19 @@ uint32_t DateTimeStateResponse::calc_size_msg(const void *self) { #endif return size; } -void DateTimeCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void DateTimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_FIXED32): - this->epoch_seconds = value.as_fixed32(); + msg.epoch_seconds = value.as_fixed32(); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } @@ -3723,30 +3792,32 @@ uint32_t UpdateStateResponse::calc_size_msg(const void *self) { #endif return size; } -void UpdateCommandRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void UpdateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->command = static_cast(value.as_varint()); + msg.command = static_cast(value.as_varint()); break; #ifdef USE_DEVICES case proto_tag(3, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif } } #endif #ifdef USE_ZWAVE_PROXY -void ZWaveProxyFrame::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ZWaveProxyFrame::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; } } @@ -3766,15 +3837,16 @@ ZWaveProxyFrame::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len); return size; } -void ZWaveProxyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void ZWaveProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->type = static_cast(value.as_varint()); + msg.type = static_cast(value.as_varint()); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; } } @@ -3846,30 +3918,32 @@ uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { } #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void InfraredRFTransmitRawTimingsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { #ifdef USE_DEVICES case proto_tag(1, WIRE_TYPE_VARINT): - this->device_id = value.as_varint(); + msg.device_id = value.as_varint(); break; #endif case proto_tag(2, WIRE_TYPE_FIXED32): - this->key = value.as_fixed32(); + msg.key = value.as_fixed32(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->carrier_frequency = value.as_varint(); + msg.carrier_frequency = value.as_varint(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->repeat_count = value.as_varint(); + msg.repeat_count = value.as_varint(); break; case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); + msg.timings_data_ = value.data(); + msg.timings_length_ = value.size(); + msg.timings_count_ = count_packed_varints(value.data(), value.size()); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->modulation = value.as_varint(); + msg.modulation = value.as_varint(); break; } } @@ -3948,26 +4022,28 @@ uint32_t ListEntitiesRadioFrequencyResponse::calc_size_msg(const void *self) { } #endif #ifdef USE_SERIAL_PROXY -void SerialProxyConfigureRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxyConfigureRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->baudrate = value.as_varint(); + msg.baudrate = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->flow_control = value.as_bool(); + msg.flow_control = value.as_bool(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->parity = static_cast(value.as_varint()); + msg.parity = static_cast(value.as_varint()); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->stop_bits = value.as_varint(); + msg.stop_bits = value.as_varint(); break; case proto_tag(6, WIRE_TYPE_VARINT): - this->data_size = value.as_varint(); + msg.data_size = value.as_varint(); break; } } @@ -3989,34 +4065,39 @@ SerialProxyDataReceived::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.data_len_); return size; } -void SerialProxyWriteRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxyWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): - this->data = value.data(); - this->data_len = value.size(); + msg.data = value.data(); + msg.data_len = value.size(); break; } } -void SerialProxySetModemPinsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxySetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->line_states = value.as_varint(); + msg.line_states = value.as_varint(); break; } } -void SerialProxyGetModemPinsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; } } @@ -4037,14 +4118,15 @@ uint32_t SerialProxyGetModemPinsResponse::calc_size_msg(const void *self) { size += msg.status ? 2 : 0; return size; } -void SerialProxyRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->type = static_cast(value.as_varint()); + msg.type = static_cast(value.as_varint()); break; } } @@ -4066,36 +4148,40 @@ uint32_t SerialProxyRequestResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_length(1, msg.error_message.size()); return size; } -void SerialProxySetModeRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->instance = value.as_varint(); + msg.instance = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->mode = static_cast(value.as_varint()); + msg.mode = static_cast(value.as_varint()); break; } } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -void BluetoothSetConnectionParamsRequest::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { +void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); const ProtoFieldValue value(data, scalar); switch (tag) { case proto_tag(1, WIRE_TYPE_VARINT): - this->address = value.as_varint(); + msg.address = value.as_varint(); break; case proto_tag(2, WIRE_TYPE_VARINT): - this->min_interval = value.as_varint(); + msg.min_interval = value.as_varint(); break; case proto_tag(3, WIRE_TYPE_VARINT): - this->max_interval = value.as_varint(); + msg.max_interval = value.as_varint(); break; case proto_tag(4, WIRE_TYPE_VARINT): - this->latency = value.as_varint(); + msg.latency = value.as_varint(); break; case proto_tag(5, WIRE_TYPE_VARINT): - this->timeout = value.as_varint(); + msg.timeout = value.as_varint(); break; } } @@ -4115,5 +4201,131 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { return size; } #endif +#ifndef HAS_PROTO_MESSAGE_DUMP +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_COVER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_FAN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LIGHT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SWITCH +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_NOISE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_STATES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_USER_DEFINED_ACTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CAMERA +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CLIMATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_WATER_HEATER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_NUMBER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SELECT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SIREN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LOCK +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BUTTON +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_MEDIA_PLAYER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VOICE_ASSISTANT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ALARM_CONTROL_PANEL +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_TEXT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_TIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VALVE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATETIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_UPDATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ZWAVE_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SERIAL_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index aa30a020640..5b3683a1ab9 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -424,12 +424,15 @@ class HelloRequest final : public ProtoDecodableMessage { StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class HelloResponse final : public ProtoMessage { public: @@ -462,6 +465,9 @@ class DisconnectRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif enums::DisconnectReason reason{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -473,7 +479,7 @@ class DisconnectRequest final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class DisconnectResponse final : public ProtoMessage { public: @@ -844,12 +850,15 @@ class CoverCommandRequest final : public CommandProtoMessage { bool has_tilt{false}; float tilt{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_FAN @@ -918,12 +927,15 @@ class FanCommandRequest final : public CommandProtoMessage { int32_t speed_level{0}; bool has_preset_mode{false}; StringRef preset_mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LIGHT @@ -1014,12 +1026,15 @@ class LightCommandRequest final : public CommandProtoMessage { uint32_t flash_length{0}; bool has_effect{false}; StringRef effect{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SENSOR @@ -1119,12 +1134,15 @@ class SwitchCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("switch_command_request"); } #endif bool state{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT_SENSOR @@ -1179,12 +1197,15 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif enums::LogLevel level{}; bool dump_config{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1222,12 +1243,15 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { #endif const uint8_t *key{nullptr}; uint16_t key_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: @@ -1316,12 +1340,15 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1357,12 +1384,15 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { StringRef entity_id{}; StringRef state{}; StringRef attribute{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif class GetTimeRequest final : public ProtoMessage { @@ -1386,12 +1416,15 @@ class DSTRule final : public ProtoDecodableMessage { uint32_t month{0}; uint32_t week{0}; uint32_t day_of_week{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1399,12 +1432,15 @@ class ParsedTimezone final : public ProtoDecodableMessage { int32_t dst_offset_seconds{0}; DSTRule dst_start{}; DSTRule dst_end{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1416,12 +1452,15 @@ class GetTimeResponse final : public ProtoDecodableMessage { uint32_t epoch_seconds{0}; ParsedTimezone parsed_timezone{}; bool has_parsed_timezone{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { @@ -1489,7 +1528,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1512,7 +1551,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1598,12 +1637,15 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif bool single{false}; bool stream{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_CLIMATE @@ -1704,12 +1746,15 @@ class ClimateCommandRequest final : public CommandProtoMessage { StringRef custom_preset{}; bool has_target_humidity{false}; float target_humidity{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_WATER_HEATER @@ -1776,12 +1821,15 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_NUMBER @@ -1839,12 +1887,15 @@ class NumberCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("number_command_request"); } #endif float state{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SELECT @@ -1897,12 +1948,15 @@ class SelectCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("select_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SIREN @@ -1963,12 +2017,15 @@ class SirenCommandRequest final : public CommandProtoMessage { uint32_t duration{0}; bool has_volume{false}; float volume{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LOCK @@ -2025,12 +2082,15 @@ class LockCommandRequest final : public CommandProtoMessage { enums::LockCommand command{}; bool has_code{false}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BUTTON @@ -2061,12 +2121,15 @@ class ButtonCommandRequest final : public CommandProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("button_command_request"); } #endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_MEDIA_PLAYER @@ -2147,12 +2210,15 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { StringRef media_url{}; bool has_announcement{false}; bool announcement{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -2164,12 +2230,15 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); } #endif uint32_t flags{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -2218,12 +2287,15 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { enums::BluetoothDeviceRequestType request_type{}; bool has_address_type{false}; uint32_t address_type{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -2256,12 +2328,15 @@ class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_request"); } #endif uint64_t address{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTDescriptor final : public ProtoMessage { public: @@ -2367,12 +2442,15 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2413,12 +2491,15 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { bool response{false}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2429,12 +2510,15 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2447,12 +2531,15 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { uint32_t handle{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2464,12 +2551,15 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { uint64_t address{0}; uint32_t handle{0}; bool enable{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2682,12 +2772,15 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); } #endif enums::BluetoothScannerMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2700,12 +2793,15 @@ class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { #endif bool subscribe{false}; uint32_t flags{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAudioSettings final : public ProtoMessage { public: @@ -2757,23 +2853,29 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif uint32_t port{0}; bool error{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: StringRef name{}; StringRef value{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: @@ -2784,12 +2886,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { #endif enums::VoiceAssistantEvent event_type{}; std::vector data{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2803,6 +2908,9 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { bool end{false}; const uint8_t *data2{nullptr}; uint16_t data2_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -2814,7 +2922,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: @@ -2829,12 +2937,15 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { uint32_t total_seconds{0}; uint32_t seconds_left{0}; bool is_active{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2847,12 +2958,15 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { StringRef text{}; StringRef preannounce_media_id{}; bool start_conversation{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2900,12 +3014,15 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { uint32_t model_size{0}; StringRef model_hash{}; StringRef url{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2915,12 +3032,15 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); } #endif std::vector external_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: @@ -2952,12 +3072,15 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); } #endif std::vector active_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -3012,12 +3135,15 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #endif enums::AlarmControlPanelStateCommand command{}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT @@ -3073,12 +3199,15 @@ class TextCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("text_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATE @@ -3134,12 +3263,15 @@ class DateCommandRequest final : public CommandProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_TIME @@ -3195,12 +3327,15 @@ class TimeCommandRequest final : public CommandProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_EVENT @@ -3301,12 +3436,15 @@ class ValveCommandRequest final : public CommandProtoMessage { bool has_position{false}; float position{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATETIME @@ -3358,12 +3496,15 @@ class DateTimeCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("date_time_command_request"); } #endif uint32_t epoch_seconds{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_UPDATE @@ -3423,12 +3564,15 @@ class UpdateCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("update_command_request"); } #endif enums::UpdateCommand command{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ZWAVE_PROXY @@ -3441,6 +3585,9 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -3452,7 +3599,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ZWaveProxyRequest final : public ProtoDecodableMessage { public: @@ -3464,6 +3611,9 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { enums::ZWaveProxyRequestType type{}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -3475,7 +3625,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ZWaveProxyRequestResponse final : public ProtoMessage { public: @@ -3540,12 +3690,15 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { uint16_t timings_length_{0}; uint16_t timings_count_{0}; uint32_t modulation{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3611,12 +3764,15 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { enums::SerialProxyParity parity{}; uint32_t stop_bits{0}; uint32_t data_size{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3654,12 +3810,15 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { uint32_t instance{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3670,12 +3829,15 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; uint32_t line_states{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3685,12 +3847,15 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); } #endif uint32_t instance{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3723,12 +3888,15 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; enums::SerialProxyRequestType type{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyRequestResponse final : public ProtoMessage { public: @@ -3762,12 +3930,15 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; enums::SerialProxyMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS @@ -3783,12 +3954,15 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { uint32_t max_interval{0}; uint32_t latency{0}; uint32_t timeout{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 6b558f7f923..fd1a9519dcd 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -216,7 +216,7 @@ void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expe #endif -void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { +void ProtoDecodableMessage::decode_fields(void *msg, const uint8_t *buffer, size_t length, DecodeFieldFn field) { const uint8_t *ptr = buffer; const uint8_t *end = buffer + length; @@ -287,7 +287,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { return; } } - this->decode_field(tag, data, scalar); + field(msg, tag, data, scalar); } } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 1a5160cb82c..c112b0f4159 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -10,6 +10,7 @@ #include #include +#include #include #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE @@ -723,7 +724,13 @@ class ProtoMessage { // Base class for messages that support decoding class ProtoDecodableMessage : public ProtoMessage { public: - void decode(const uint8_t *buffer, size_t length); + /// Stores one decoded field into \p msg; generated per message type. \p scalar is the varint or + /// fixed32 value, or the length of the length-delimited payload at \p data. An unknown field or + /// wrong wire type matches no case and is skipped. + using DecodeFieldFn = void (*)(void *msg, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); + /// Walk \p buffer and hand every field to \p field. The generated decode() passes the message's + /// own decode_field, so decodable messages carry no vtable. + static void decode_fields(void *msg, const uint8_t *buffer, size_t length, DecodeFieldFn field); /** * Count occurrences of a repeated field in a protobuf buffer. @@ -735,14 +742,15 @@ class ProtoDecodableMessage : public ProtoMessage { * @return Number of times the field appears in the buffer */ static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id); - - protected: - ~ProtoDecodableMessage() = default; - /// Store one decoded field; \p scalar is the varint or fixed32 value, or the length of the - /// length-delimited payload at \p data. An unknown field or wrong wire type matches no case and is skipped. - /// Three register arguments keep the decode loop free of spills. - virtual void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {} + // The destructor stays accessible on purpose: the generated messages are aggregates that brace + // initialise sub message members, which copies a base temporary. That trades away the compile time + // guard against deleting through this type; messages are stack locals and never owned through a base + // pointer. ProtoMessage keeps its guard for the dump builds. }; +#ifndef HAS_PROTO_MESSAGE_DUMP +// decode() passes decode_field explicitly, so nothing here may add a vtable +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif class ProtoSize { public: diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index bdfa58a3b3b..db548ae4f62 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2536,8 +2536,8 @@ def build_message_type( # Get source direction to determine if we need decode/encode methods source = message_source_map[desc.name] - needs_decode = source in (SOURCE_BOTH, SOURCE_CLIENT) - needs_encode = source in (SOURCE_BOTH, SOURCE_SERVER) + needs_decode = message_needs_decode(source) + needs_encode = message_needs_encode(source) # Add MESSAGE_TYPE method if this is a service message if message_id is not None: @@ -2661,15 +2661,22 @@ def build_message_type( cpp = "" if decode: - o = f"void {desc.name}::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {{\n" + o = f"void {desc.name}::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {{\n" + o += f" auto &msg = *static_cast<{desc.name} *>(self);\n" o += " const ProtoFieldValue value(data, scalar);\n" o += " switch (tag) {\n" - o += indent("\n".join(decode), " ") + "\n" + o += indent("\n".join(decode), " ").replace("this->", "msg.") + "\n" o += " }\n" o += "}\n" cpp += o - prot = "void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override;" + prot = "static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar);" protected_content.insert(0, prot) + if not fixed_vector_fields: + public_content.append( + "void decode(const uint8_t *buffer, size_t length) {\n" + " ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);\n" + "}" + ) # Generate custom decode() override for messages with FixedVector fields if fixed_vector_fields: @@ -2679,8 +2686,8 @@ def build_message_type( for field_name, field_number in fixed_vector_fields: o += f" uint32_t count_{field_name} = ProtoDecodableMessage::count_repeated_field(buffer, length, {field_number});\n" o += f" this->{field_name}.init(count_{field_name});\n" - # Call parent decode to populate the fields - o += " ProtoDecodableMessage::decode(buffer, length);\n" + # Then the shared loop fills them + o += " ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);\n" o += "}\n" cpp += o # Generate the decode() declaration in header (public method) @@ -2839,6 +2846,23 @@ def get_field_opt( return field.options.Extensions[opt] +def message_needs_decode(source: int) -> bool: + return source in (SOURCE_BOTH, SOURCE_CLIENT) + + +def message_needs_encode(source: int) -> bool: + return source in (SOURCE_BOTH, SOURCE_SERVER) + + +def is_decodable_class(desc: descriptor.DescriptorProto, source: int) -> bool: + """Whether the generated class derives from ProtoDecodableMessage: decoded, and either on a + decodable base class or with at least one live field.""" + return message_needs_decode(source) and ( + get_base_class(desc) is not None + or any(not field.options.deprecated for field in desc.field) + ) + + def get_base_class(desc: descriptor.DescriptorProto) -> str | None: """Get the base_class option from a message descriptor.""" if not desc.options.HasExtension(pb.base_class): @@ -2940,11 +2964,11 @@ def build_base_class( # Determine if any message using this base class needs decoding/encoding needs_decode = any( - message_source_map.get(msg.name, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_CLIENT) + message_needs_decode(message_source_map.get(msg.name, SOURCE_BOTH)) for msg in messages ) needs_encode = any( - message_source_map.get(msg.name, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_SERVER) + message_needs_encode(message_source_map.get(msg.name, SOURCE_BOTH)) for msg in messages ) @@ -3378,6 +3402,7 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint # Generate message types with base class information # Simple grouping by ifdef + decodable_messages: list[tuple[str, str | None]] = [] current_ifdef = None for m in mt: @@ -3394,6 +3419,8 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint continue s, c, dc = build_message_type(m, base_class_fields, message_source_map) + if is_decodable_class(m, message_source_map[m.name]): + decodable_messages.append((m.name, message_ifdef_map.get(m.name))) msg_ifdef = message_ifdef_map.get(m.name) # Handle ifdef changes @@ -3420,6 +3447,22 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint cpp += "#endif\n" dump_cpp += "#endif\n" + # decode() passes decode_field explicitly, so without the dump virtuals no decodable message + # may carry a vtable; a build at any level below VERY_VERBOSE proves it + cpp += "#ifndef HAS_PROTO_MESSAGE_DUMP\n" + assert_ifdef = None + for name, msg_ifdef in decodable_messages: + if msg_ifdef != assert_ifdef: + if assert_ifdef is not None: + cpp += "#endif\n" + if msg_ifdef is not None: + cpp += _make_ifdef_line(msg_ifdef) + "\n" + assert_ifdef = msg_ifdef + cpp += f'static_assert(!std::is_polymorphic_v<{name}>, "decodable messages carry no vtable");\n' + if assert_ifdef is not None: + cpp += "#endif\n" + cpp += "#endif\n" + content += """\ } // namespace esphome::api diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 20a6f87c453..2e4eb67a241 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -287,18 +287,19 @@ def test_a_fixed64_field_fails_at_generation_time() -> None: build_message_type(desc, {}, {"Wide": SOURCE_CLIENT}) -def test_message_gets_a_single_decode_field_override() -> None: - """All wire types of a decoded message land in one decode_field() switch.""" +def test_message_decodes_through_one_static_decode_field() -> None: + """All wire types of a decoded message land in one static decode_field() switch that the + inline decode() hands to the shared loop.""" desc = descriptor_pb2.DescriptorProto(name="Mixed") desc.field.add(name="name", number=1, type=STRING) desc.field.add(name="count", number=2, type=UINT32) desc.field.add(name="level", number=3, type=FLOAT) header, cpp, _ = build_message_type(desc, {}, {"Mixed": SOURCE_CLIENT}) - decl = "void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override;" + decl = "static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar);" assert header.count(decl) == 1 assert ( cpp.count( - "void Mixed::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {" + "void Mixed::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {" ) == 1 ) @@ -310,6 +311,37 @@ def test_message_gets_a_single_decode_field_override() -> None: (3, "WIRE_TYPE_FIXED32"), ): assert f"case proto_tag({number}, {wire_type}):" in cpp, cpp + # The static body works on the cast message, never on this + assert "auto &msg = *static_cast(self);" in cpp + assert "this->" not in cpp + assert ( + header.count( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + ) + == 1 + ) + + +def test_fixed_vector_message_keeps_its_own_decode() -> None: + """A message that sizes a FixedVector first decodes through its own decode(), not the inline one.""" + desc = descriptor_pb2.DescriptorProto(name="Sized") + field = desc.field.add(name="values", number=1, type=UINT32) + field.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED + field.options.Extensions[pb.fixed_vector] = True + header, cpp, _ = build_message_type(desc, {}, {"Sized": SOURCE_CLIENT}) + assert ( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + not in header + ) + assert header.count("void decode(const uint8_t *buffer, size_t length);") == 1 + assert "void Sized::decode(const uint8_t *buffer, size_t length) {" in cpp + assert "ProtoDecodableMessage::count_repeated_field(buffer, length, 1)" in cpp + assert ( + cpp.count( + "ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);" + ) + == 1 + ) @pytest.mark.parametrize("source", [SOURCE_CLIENT, SOURCE_SERVER, SOURCE_BOTH]) From e63428ddce7125318503ebfb40462f823b0bfd56 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:31:16 +0100 Subject: [PATCH 447/759] [nextion] Keep add_waveform_data() available without a waveform sensor (#19590) --- esphome/components/nextion/nextion.h | 2 -- esphome/components/nextion/nextion_commands.cpp | 2 -- tests/components/nextion/common_waveform_lambda.yaml | 8 ++++++++ .../nextion/test-waveform-lambda.esp32-idf.yaml | 3 +++ 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 tests/components/nextion/common_waveform_lambda.yaml create mode 100644 tests/components/nextion/test-waveform-lambda.esp32-idf.yaml diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 6c9c8760f8a..320aec048a0 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -614,7 +614,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: */ void disable_component_touch(const char *component); -#ifdef USE_NEXTION_WAVEFORM /** * Add waveform data to a waveform component * @param component_id The integer component id. @@ -624,7 +623,6 @@ class Nextion final : public NextionBase, public PollingComponent, public uart:: void add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value); void open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value); -#endif // USE_NEXTION_WAVEFORM /** * Display a picture at coordinates. diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a356d54e2f1..dd3a97065cb 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -217,7 +217,6 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } -#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -227,7 +226,6 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } -#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); diff --git a/tests/components/nextion/common_waveform_lambda.yaml b/tests/components/nextion/common_waveform_lambda.yaml new file mode 100644 index 00000000000..70b1c62d87d --- /dev/null +++ b/tests/components/nextion/common_waveform_lambda.yaml @@ -0,0 +1,8 @@ +# Waveform commands from a lambda with no waveform sensor configured, so +# USE_NEXTION_WAVEFORM is not defined. +display: + - platform: nextion + id: main_lcd + lambda: |- + id(main_lcd).open_waveform_channel(37, 0, 10); + id(main_lcd).add_waveform_data(37, 0, 42); diff --git a/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml b/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml new file mode 100644 index 00000000000..53535ab6c46 --- /dev/null +++ b/tests/components/nextion/test-waveform-lambda.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + waveform_lambda: !include common_waveform_lambda.yaml From 9df7e825d30bb119afe9d9cb59cfff02928c9d57 Mon Sep 17 00:00:00 2001 From: Janne Snabb Date: Thu, 24 Sep 2026 19:59:03 +0300 Subject: [PATCH 448/759] [core] Keep static task TCB in internal RAM (#19426) --- esphome/core/static_task.cpp | 31 +++++++++++++++++-------------- esphome/core/static_task.h | 25 +++++++++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 43011083159..87629c2507d 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -14,11 +14,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size } if (this->stack_buffer_ != nullptr && (stack_size > this->stack_size_ || use_psram != this->use_psram_)) { - // Existing buffer is too small or wrong memory type; deallocate to reallocate below - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; + // Existing buffer is too small or wrong memory type; free it to reallocate below + this->stack_buffer_.reset(); } if (this->stack_buffer_ == nullptr) { @@ -26,13 +23,23 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size this->use_psram_ = use_psram; RAMAllocator allocator(use_psram ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); - this->stack_buffer_ = allocator.allocate(stack_size); + this->stack_buffer_ = allocator.make_unique_array_for_overwrite(stack_size); } if (this->stack_buffer_ == nullptr) { return false; } - this->handle_ = xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_, &this->tcb_); + if (this->tcb_ == nullptr) { + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->tcb_ = allocator.make_unique(); + } + if (this->tcb_ == nullptr) { + this->deallocate(); + return false; + } + + this->handle_ = + xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); if (this->handle_ == nullptr) { this->deallocate(); return false; @@ -65,13 +72,9 @@ bool StaticTask::deallocate() { if (!this->destroy()) { return false; } - if (this->stack_buffer_ != nullptr) { - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; - this->stack_size_ = 0; - } + this->stack_buffer_.reset(); + this->stack_size_ = 0; + this->tcb_.reset(); return true; } diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index e2996abedae..10c55f70c62 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -5,49 +5,58 @@ #include #include +#include "esphome/core/helpers.h" + #include namespace esphome { /** Helper for FreeRTOS static task management. - * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Stores the TCB in internal RAM and manages the stack buffer with create/destroy methods. * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: + StaticTask() = default; + // Non-copyable, non-movable — the task handle and its buffers are not transferable + StaticTask(const StaticTask &) = delete; + StaticTask &operator=(const StaticTask &) = delete; + StaticTask(StaticTask &&) = delete; + StaticTask &operator=(StaticTask &&) = delete; + /// @brief Check if the task has been created and not yet destroyed. bool is_created() const { return this->handle_ != nullptr; } /// @brief Get the FreeRTOS task handle. TaskHandle_t get_handle() const { return this->handle_; } - /// @brief Allocate stack and create task. + /// @brief Allocate the TCB (always internal RAM) and the stack, then create the task. /// @param fn Task function /// @param name Task name (for debug) /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM - /// @return true on success + /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. /// @return true if the task was deleted; false if it is still running on another core, in which case the /// caller should try again later. bool destroy(); - /// @brief Delete the task (if created) and free the stack buffer. - /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// @brief Delete the task (if created) and free the TCB and stack buffer. + /// @return true if the buffers were freed; false if the task is still running on another core, in /// which case the caller should try again later. bool deallocate(); protected: TaskHandle_t handle_{nullptr}; - StaticTask_t tcb_; - StackType_t *stack_buffer_{nullptr}; + RAMUniquePtr tcb_; + RAMUniquePtr stack_buffer_; uint32_t stack_size_{0}; bool use_psram_{false}; }; From 211fd3ce542f10705480f579cbc9e954ee3e9fd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 18:24:10 +0100 Subject: [PATCH 449/759] [core] Dispatch controller updates through generated direct calls (#19588) --- esphome/codegen.py | 1 - esphome/components/api/__init__.py | 5 +- esphome/components/api/api_server.cpp | 3 - esphome/components/api/api_server.h | 47 ++++++++------- esphome/components/api/subscribe_state.h | 1 - .../prometheus/prometheus_handler.h | 2 +- esphome/components/web_server/__init__.py | 5 +- esphome/components/web_server/web_server.cpp | 2 - esphome/components/web_server/web_server.h | 49 ++++++++------- esphome/core/__init__.py | 11 ++-- esphome/core/component_iterator.h | 2 +- esphome/core/config.py | 26 +++++--- esphome/core/controller.h | 19 ------ esphome/core/controller_dispatch.h | 50 ++++++++++++++++ esphome/core/controller_registry.cpp | 11 ---- esphome/core/controller_registry.h | 59 ++----------------- esphome/core/defines.h | 1 - esphome/cpp_types.py | 1 - esphome/writer.py | 23 ++++---- script/helpers.py | 6 +- tests/components/api/__init__.py | 2 - tests/unit_tests/core/test_config.py | 32 +++++++++- tests/unit_tests/test_codegen.py | 1 - 23 files changed, 178 insertions(+), 181 deletions(-) delete mode 100644 esphome/core/controller.h create mode 100644 esphome/core/controller_dispatch.h delete mode 100644 esphome/core/controller_registry.cpp diff --git a/esphome/codegen.py b/esphome/codegen.py index 5debb52b4e2..3dfec65f233 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -64,7 +64,6 @@ from esphome.cpp_types import ( # noqa: F401 Application, Component, ComponentPtr, - Controller, EntityBase, EntityCategory, ESPTime, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 854bceecfad..2291dc54f8f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -81,7 +81,7 @@ def AUTO_LOAD(config: ConfigType) -> list[str]: api_ns = cg.esphome_ns.namespace("api") -APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) +APIServer = api_ns.class_("APIServer", cg.Component) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) @@ -462,8 +462,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) # Request a log listener slot for API log streaming request_log_listener() diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 310cdcf67bb..2a89d31a74a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -5,7 +5,6 @@ #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -61,8 +60,6 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { - ControllerRegistry::register_controller(this); - #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index b8a71c93e7a..49cf77ca5af 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,7 +14,7 @@ #include "esphome/components/socket/socket.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" #ifdef USE_PROVISIONING @@ -49,8 +49,7 @@ struct SavedNoisePsk { bool load_saved_noise_psk(noise::psk_t &out); #endif -class APIServer final : public Component, - public Controller +class APIServer final : public Component #ifdef USE_CAMERA , public camera::CameraListener @@ -94,58 +93,58 @@ class APIServer final : public Component, void handle_disconnect(APIConnection *conn); #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); #endif #ifdef USE_SENSOR - void on_sensor_update(sensor::Sensor *obj) override; + void on_sensor_update(sensor::Sensor *obj); #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); #endif #ifdef USE_MEDIA_PLAYER - void on_media_player_update(media_player::MediaPlayer *obj) override; + void on_media_player_update(media_player::MediaPlayer *obj); #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); #endif #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); @@ -188,13 +187,13 @@ class APIServer final : public Component, #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); #endif #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); #endif #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 6b1ae9651de..36e96e10819 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -4,7 +4,6 @@ #ifdef USE_API #include "esphome/core/component.h" #include "esphome/core/component_iterator.h" -#include "esphome/core/controller.h" namespace esphome::api { class APIConnection; diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 5574039e41f..9dcbf7e6869 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -6,8 +6,8 @@ #include "esphome/components/web_server_base/web_server_base.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/progmem.h" #ifdef USE_CLIMATE #include "esphome/core/log.h" diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2459163786d..3ec365cf381 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -62,7 +62,7 @@ DEFAULT_PORT = 80 web_server_ns = cg.esphome_ns.namespace("web_server") -WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) +WebServer = web_server_ns.class_("WebServer", cg.Component) sorting_groups = {} @@ -378,8 +378,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) version = config[CONF_VERSION] diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 49943f8a71b..4906a2050ae 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -5,7 +5,6 @@ #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -365,7 +364,6 @@ json::SerializationBuffer<> WebServer::get_config_json() { } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3e50eeeec07..3236ccfd3c8 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -6,8 +6,8 @@ #include "esphome/components/web_server_base/web_server_base.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -187,7 +187,7 @@ class DeferredUpdateEventSourceList final : public std::list'. void handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -301,7 +301,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); /// Handle a switch request under '/switch//'. void handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -319,7 +319,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); /// Handle a binary sensor request under '/binary_sensor/'. void handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -329,7 +329,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); /// Handle a fan request under '/fan//'. void handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -339,7 +339,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); /// Handle a light request under '/light//'. void handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -349,7 +349,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); /// Handle a text sensor request under '/text_sensor/'. void handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -359,7 +359,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); /// Handle a cover request under '/cover//'. void handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -369,7 +369,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); /// Handle a number request under '/number/'. void handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -378,7 +378,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); /// Handle a date request under '/date/'. void handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -387,7 +387,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); /// Handle a time request under '/time/'. void handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -396,7 +396,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); /// Handle a datetime request under '/datetime/'. void handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -405,7 +405,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); /// Handle a text input request under '/text/'. void handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -414,7 +414,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); /// Handle a select request under '/select/'. void handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -423,7 +423,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); /// Handle a climate request under '/climate/'. void handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -432,7 +432,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); /// Handle a lock request under '/lock//'. void handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -442,7 +442,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); /// Handle a valve request under '/valve//'. void handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -452,7 +452,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); /// Handle a alarm_control_panel request under '/alarm_control_panel/'. void handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -462,7 +462,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); /// Handle a water_heater request under '/water_heater//'. void handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -484,8 +484,13 @@ class WebServer final : public Controller, public Component, public AsyncWebHand static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_MEDIA_PLAYER + // Not exposed over HTTP; the stub only satisfies ControllerContract + void on_media_player_update(media_player::MediaPlayer *) {} +#endif + #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); static json::SerializationBuffer<> event_state_json_generator(WebServer *web_server, void *source); static json::SerializationBuffer<> event_all_json_generator(WebServer *web_server, void *source); @@ -495,7 +500,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); /// Handle a update request under '/update/'. void handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match); diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5fcad90a81a..fb752850817 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -52,8 +52,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing -KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# Key for the controllers (APIServer, WebServer) that receive entity state updates +KEY_CONTROLLER_REGISTRY_CONTROLLERS = "controller_registry_controllers" # CORE.data key for the "is_rp2040 deprecation warning already fired this # run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared @@ -1209,10 +1209,9 @@ class EsphomeCore: if not self.platform_counts[platform_name]: self.platform_counts[platform_name] = 1 - def register_controller(self) -> None: - """Track registration of a Controller for ControllerRegistry StaticVector sizing.""" - controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0) - self.data[KEY_CONTROLLER_REGISTRY_COUNT] = controller_count + 1 + def register_controller(self, controller: "MockObj") -> None: + """Register a controller that receives every entity state update.""" + self.data.setdefault(KEY_CONTROLLER_REGISTRY_CONTROLLERS, []).append(controller) @property def cpp_main_section(self): diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index fac09e9e145..3c11d4a880f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" #ifdef USE_CAMERA diff --git a/esphome/core/config.py b/esphome/core/config.py index 051a136aaba..4d3fb833124 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -46,7 +46,7 @@ from esphome.const import ( ) from esphome.core import ( CORE, - KEY_CONTROLLER_REGISTRY_COUNT, + KEY_CONTROLLER_REGISTRY_CONTROLLERS, CoroPriority, coroutine_with_priority, ) @@ -674,12 +674,22 @@ async def _add_platform_defines() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def _add_controller_registry_define() -> None: - # Generate StaticVector size for ControllerRegistry - controller_count = CORE.data.get(KEY_CONTROLLER_REGISTRY_COUNT, 0) - if controller_count > 0: - cg.add_define("USE_CONTROLLER_REGISTRY") - cg.add_define("CONTROLLER_REGISTRY_MAX", controller_count) +async def _add_controller_registry_dispatch() -> None: + # controller_dispatch.h defines ControllerRegistry::notify_*() as direct + # calls on the controllers returned by esphome_controllers(), emitted as + # static auto esphome_controllers() { return std::tuple{a, b}; } + controllers = CORE.data.get(KEY_CONTROLLER_REGISTRY_CONTROLLERS) + if not controllers: + return + cg.add_define("USE_CONTROLLER_REGISTRY") + controllers = cg.ArrayInitializer(*controllers) + cg.add_global(cg.RawStatement("#include ")) + cg.add_global( + cg.RawStatement( + f"static auto esphome_controllers() {{ return std::tuple{controllers}; }}" + ) + ) + cg.add_global(cg.RawStatement('#include "esphome/core/controller_dispatch.h"')) @coroutine_with_priority(CoroPriority.FINAL) @@ -755,7 +765,7 @@ async def to_code(config: ConfigType) -> None: ) CORE.add_job(_add_platform_defines) - CORE.add_job(_add_controller_registry_define) + CORE.add_job(_add_controller_registry_dispatch) CORE.add_job(_add_looping_components) CORE.add_job(_add_automations, config) diff --git a/esphome/core/controller.h b/esphome/core/controller.h deleted file mode 100644 index 09975b465f8..00000000000 --- a/esphome/core/controller.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/entity_includes.h" - -namespace esphome { - -class Controller { - public: -// Controller virtual methods (generated from entity_types.h) -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ - // NOLINTEND(bugprone-macro-parentheses) -}; - -} // namespace esphome diff --git a/esphome/core/controller_dispatch.h b/esphome/core/controller_dispatch.h new file mode 100644 index 00000000000..fc6a03556df --- /dev/null +++ b/esphome/core/controller_dispatch.h @@ -0,0 +1,50 @@ +#pragma once + +// Included once by the generated main.cpp, after it returns the registered controllers as a tuple: +// +// static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; } +// #include "esphome/core/controller_dispatch.h" +// +// Defines ControllerRegistry::notify_*() as direct calls on those controllers. Excluded from +// esphome.h and the clang-tidy all-headers file, so nothing else includes it. + +#include +#include + +#include "esphome/core/controller_registry.h" + +namespace esphome { + +// NOLINTBEGIN(bugprone-macro-parentheses) + +/// A controller provides a plain on_*_update() member for every entity type in the build. +template +concept ControllerContract = requires(T &controller) { + controller; // keeps the requirement list non-empty when no entity type has a callback +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + controller.on_##callback(static_cast(nullptr)); +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +}; + +template constexpr bool controllers_satisfy_contract(std::tuple *) { + return (ControllerContract> && ...); +} +static_assert(controllers_satisfy_contract(static_cast(nullptr)), + "A registered controller is missing an on_*_update() callback for an entity type in this build " + "(ControllerContract in esphome/core/controller_dispatch.h)"); + +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + void ControllerRegistry::notify_##callback(type *obj) { \ + std::apply([obj](auto *...controller) { (controller->on_##callback(obj), ...); }, esphome_controllers()); \ + } +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + +// NOLINTEND(bugprone-macro-parentheses) + +} // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp deleted file mode 100644 index 907e0f923d6..00000000000 --- a/esphome/core/controller_registry.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include "esphome/core/controller_registry.h" - -#ifdef USE_CONTROLLER_REGISTRY - -namespace esphome { - -StaticVector ControllerRegistry::controllers; - -} // namespace esphome - -#endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index c6113116ffd..8b1b6c7f9d5 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -5,37 +5,18 @@ #ifdef USE_CONTROLLER_REGISTRY #include "esphome/core/entity_includes.h" -#include "esphome/core/helpers.h" namespace esphome { -class Controller; - -/** Global registry for Controllers to receive entity state updates. +/** Fan-out of entity state updates to the controllers (APIServer, WebServer). * - * This singleton registry allows Controllers (APIServer, WebServer) to receive - * entity state change notifications without storing per-entity callbacks. - * - * Instead of each entity maintaining controller callbacks (32 bytes overhead per entity), - * entities call ControllerRegistry::notify_*_update() which iterates the small list - * of registered controllers (typically 2: API and WebServer). - * - * Each notify method directly iterates controllers and calls the virtual method, - * avoiding function pointer indirection for minimal dispatch overhead. - * - * Memory savings: 32 bytes per entity (2 controllers × 16 bytes std::function overhead) - * Typical config (25 entities): ~780 bytes saved - * Large config (80 entities): ~2,540 bytes saved + * Entities call ControllerRegistry::notify_*_update() instead of holding + * per-entity controller callbacks. The functions are only declared here; + * controller_dispatch.h, included by the generated main.cpp, defines them as + * direct calls on the controllers registered through CORE.register_controller(). */ class ControllerRegistry { public: - /** Register a controller to receive entity state updates. - * - * Controllers should call this in their setup() method. - * Typically only APIServer and WebServer register. - */ - static void register_controller(Controller *controller) { controllers.push_back(controller); } - // Notify method declarations (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback @@ -45,38 +26,8 @@ class ControllerRegistry { #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) - - protected: - static StaticVector controllers; }; } // namespace esphome -// Include controller.h AFTER the class definition so notify methods can be -// defined inline. This is safe because controller_registry.h is only ever -// included from .cpp files, never from other headers. -#include "esphome/core/controller.h" - -namespace esphome { - -// Inline notify methods — each is a tiny loop over 1-2 controllers. -// Defining them here (rather than in controller_registry.cpp) allows the -// compiler to inline them into the single call site in each entity's -// notify_frontend_(), eliminating an unnecessary function-call frame. - -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ - inline void ControllerRegistry::notify_##callback(type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##callback(obj); \ - } \ - } -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ -// NOLINTEND(bugprone-macro-parentheses) - -} // namespace esphome - #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7bc08ea5778..e1ae1d5fb60 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -629,7 +629,6 @@ #define USE_DASHBOARD_IMPORT // Default counts for static analysis -#define CONTROLLER_REGISTRY_MAX 2 #define ESPHOME_AREA_COUNT 10 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 45d6559b3f4..04892f5feaa 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -37,7 +37,6 @@ optional = global_ns.namespace("std").class_("optional") arduino_json_ns = global_ns.namespace("ArduinoJson") JsonObject = arduino_json_ns.class_("JsonObject") JsonObjectConst = arduino_json_ns.class_("JsonObjectConst") -Controller = esphome_ns.class_("Controller") GPIOPin = esphome_ns.class_("GPIOPin") InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin) gpio_ns = esphome_ns.namespace("gpio") diff --git a/esphome/writer.py b/esphome/writer.py index 0b9e7669ef9..9ab5d644c51 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -211,6 +211,16 @@ VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" +# Headers that must not be included bare from esphome.h or the clang-tidy +# all-headers file: X-macro files, headers main.cpp includes itself, and +# deprecated headers that only resolve when their new component is loaded. +ESPHOME_H_EXCLUDE = { + Path(ENTITY_TYPES_H_TARGET), + # main.cpp includes it after defining esphome_controllers() + Path("esphome/core/controller_dispatch.h"), + # moved to components/ring_buffer/, removed in 2026.11.0 + Path("esphome/core/ring_buffer.h"), +} ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -236,20 +246,9 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in esphome.h - # Deprecated headers that re-export from a relocated component must not be - # auto-included, since their #include of the new path only resolves when the - # new component is loaded by a consumer. - esphome_h_exclude = { - Path(ENTITY_TYPES_H_TARGET), - Path( - "esphome/core/ring_buffer.h" - ), # moved to components/ring_buffer/, removed in 2026.11.0 - } include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in ESPHOME_H_EXCLUDE: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) diff --git a/script/helpers.py b/script/helpers.py index a8a237118f9..fb2c964bdd9 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -429,11 +429,9 @@ def build_all_include(header_files: list[str] | None = None) -> None: if line ] - from esphome.writer import ENTITY_TYPES_H_TARGET + from esphome.writer import ESPHOME_H_EXCLUDE - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in the all-include header - exclude = {ENTITY_TYPES_H_TARGET} + exclude = {path.as_posix() for path in ESPHOME_H_EXCLUDE} headers = [f'#include "{h}"' for h in header_files if h not in exclude] headers.sort() headers.append("") diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 2aa558726c3..a5e3731c751 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -1,5 +1,4 @@ import esphome.codegen as cg -from esphome.core import CORE from tests.testing_helpers import ComponentManifestOverride @@ -12,6 +11,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") - CORE.register_controller() # api_server registers with the controller registry manifest.to_code = to_code_testing diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 07cff003cdd..0db70396687 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core +import esphome.codegen as cg from esphome.components.safe_mode import to_code as safe_mode_to_code from esphome.const import ( CONF_AREA, @@ -23,7 +24,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE, config +from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config from esphome.core.config import ( Area, make_app_name_cpp, @@ -455,6 +456,35 @@ async def test_add_looping_components_with_entries() -> None: assert "(1 * HasLoopOverride::value)" in text +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_without_controllers() -> None: + """Nothing is emitted when no controller registered.""" + CORE.data.pop(KEY_CONTROLLER_REGISTRY_CONTROLLERS, None) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" not in {d.name for d in CORE.defines} + assert not [s for s in CORE.global_statements if "controller" in str(s)] + + +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_with_controllers() -> None: + """Registered controllers become one tuple plus the dispatch include.""" + CORE.register_controller(cg.MockObj("api_apiserver_id")) + CORE.register_controller(cg.MockObj("web_server_webserver_id")) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" in {d.name for d in CORE.defines} + statements = [str(s) for s in CORE.global_statements] + assert "#include " in statements + assert ( + "static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; }" + in statements + ) + assert '#include "esphome/core/controller_dispatch.h"' in statements + + def test_valid_include_with_angle_brackets() -> None: """Test valid_include accepts angle bracket includes.""" assert valid_include("") == "" diff --git a/tests/unit_tests/test_codegen.py b/tests/unit_tests/test_codegen.py index 8d01fef7c20..c5c971b22dc 100644 --- a/tests/unit_tests/test_codegen.py +++ b/tests/unit_tests/test_codegen.py @@ -70,7 +70,6 @@ from esphome.cpp_generator import _extract_component_ns "arduino_json_ns", "JsonObject", "JsonObjectConst", - "Controller", "GPIOPin", ), ) From a9111a89933d46246da9afd608a5d6860c9d9ae3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 18:36:45 +0100 Subject: [PATCH 450/759] [ota] Treat a client that leaves after the handshake as a clean close (#19401) --- .../components/esphome/ota/ota_esphome.cpp | 31 ++++++++++++++- esphome/components/esphome/ota/ota_esphome.h | 2 + tests/integration/test_host_ota.py | 39 ++++++++++++++++++- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d353d01d205..6248474624b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -467,6 +467,8 @@ void ESPHomeOTAComponent::handle_data_() { if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { + if (this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("OTA type")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -476,6 +478,9 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->data_readall_(buf, 4)) { + // The first request byte is the type on the extended protocol; a close after it was a cut-off request + if (!this->extended_proto_() && this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -542,6 +547,8 @@ void ESPHomeOTAComponent::handle_data_() { // there is no would-block retry here and failures are already logged. read = this->noise_read_data_(buf, requested); if (read <= 0) { + if (this->remote_closed_) + this->log_remote_closed_(LOG_STR("data")); error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -666,7 +673,11 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); + // A partial message is a cut-off request, not a clean close; the caller reports the clean one + this->remote_closed_ = at == 0; + if (at > 0) { + ESP_LOGW(TAG, "Remote closed after %u of %zu bytes", (unsigned) at, len); + } return false; } else { at += read; @@ -712,7 +723,22 @@ void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); } -void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } +bool ESPHomeOTAComponent::client_left_before_start_() { + // Key probes and scanners hang up right after the handshake; nothing started, so no error status or callback + if (!this->remote_closed_) + return false; + ESP_LOGD(TAG, "Client left after the handshake"); + this->cleanup_connection_(); + return true; +} + +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { + if (this->remote_closed_) { + this->log_remote_closed_(what); + return; + } + ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); +} void ESPHomeOTAComponent::log_start_(const LogString *phase) { char peername[socket::SOCKADDR_STR_LEN]; @@ -793,6 +819,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->handshake_buf_pos_ = 0; this->ota_state_ = OTAState::IDLE; this->ota_features_ = 0; + this->remote_closed_ = false; this->backend_ = nullptr; #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 92ba094c8de..6f04b78da5b 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -134,6 +134,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { void server_failed_(const LogString *msg); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); + bool client_left_before_start_(); void log_start_(const LogString *phase); void log_remote_closed_(const LogString *during); void cleanup_connection_(); @@ -186,6 +187,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; + bool remote_closed_{false}; // the peer hung up cleanly during a blocking read #ifdef USE_OTA_PASSWORD uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 88eb0168e36..56a685eac3a 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -167,6 +167,33 @@ class _Device: assert self.proc.returncode is None, "process died on rejected OTA" +def _handshake_then_close(port: int, noise_psk: str) -> None: + """Negotiate and complete the Noise handshake like a key probe, then + hang up without sending an OTA type.""" + with socket.create_connection((LOCALHOST, port), timeout=5.0) as sock: + espota2.send_check(sock, espota2.MAGIC_BYTES, "magic bytes") + _, version = espota2.receive_exactly(sock, 2, "version", espota2.RESPONSE_OK) + features_to_send = ( + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_NOISE + ) + espota2.send_check(sock, features_to_send, "features") + espota2.receive_exactly(sock, 1, "features", espota2.RESPONSE_FEATURE_FLAGS) + (features,) = espota2.receive_exactly(sock, 1, "feature flags", None) + assert features & espota2.SERVER_FEATURE_SUPPORTS_NOISE + prologue = ( + espota2.NOISE_PROLOGUE_INIT + + bytes(espota2.MAGIC_BYTES) + + bytes([espota2.RESPONSE_OK, version, features_to_send]) + + bytes([espota2.RESPONSE_FEATURE_FLAGS, features]) + ) + noise = espota2.NoiseSocketWrapper(sock, noise_psk, prologue) + noise.do_handshake() + espota2.receive_exactly(noise, 1, "auth", espota2.RESPONSE_AUTH_OK) + + async def _provision_key( dev: _Device, api_client_connected: APIClientConnectedFactory ) -> None: @@ -221,16 +248,24 @@ async def test_host_ota_encrypted( compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], ) -> None: - """Encrypted self-OTA succeeds; a plaintext upload to the same device fails.""" + """A client that leaves right after the handshake, as a key probe does, + is a clean close, not an OTA error; a plaintext upload is refused; an + encrypted self-OTA succeeds.""" pytest.importorskip("aioesphomeapi.noise") dev = _Device( *await _build( yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port ) ) - async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): dev.proc = proc await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await asyncio.get_running_loop().run_in_executor( + None, _handshake_then_close, dev.ota_port, API_KEY + ) + # The error path logs its warning instead of this line, never after it + await _wait_for_line(lines, "Client left after the handshake") + assert not [line for line in lines if "[W][esphome.ota" in line] await dev.refused_ota( None, None, "plaintext upload to an encrypted device must fail" ) From 16757a84e682899c4e08f9a4b359adc25187d030 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 24 Sep 2026 18:57:16 +0100 Subject: [PATCH 451/759] [nrf52] add device power manager support to deep sleep component (#19494) --- esphome/components/deep_sleep/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index dc037086452..d5d70775834 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -422,8 +422,12 @@ async def to_code(config: ConfigType) -> None: if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) - if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: - zephyr_add_prj_conf("POWEROFF", True) + if CORE.using_zephyr: + # Devices are suspended when CPU is entering a low power state + # https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management + zephyr_add_prj_conf("PM_DEVICE", True) + if "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") From 057bf88e4af52287741c3d0afb21074d749ae1f9 Mon Sep 17 00:00:00 2001 From: oxynatOr <98734567+oxynatOr@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:06:07 +0200 Subject: [PATCH 452/759] [mcp3204] Read a conversion in one SPI transaction (#19423) Co-authored-by: Claude Opus 5 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 --- esphome/components/mcp3204/mcp3204.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 33abbe847a3..ef8b5684535 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -18,19 +18,18 @@ void MCP3204::dump_config() { } float MCP3204::read_data(uint8_t pin, bool differential) { - uint8_t command, b0, b1; - - command = (1 << 6) | // start bit - ((differential ? 0 : 1) << 5) | // single or differential bit - ((pin & 0x07) << 2); // pin + const uint8_t command = (1 << 6) | // start bit + ((differential ? 0 : 1) << 5) | // single or differential bit + ((pin & 0x07) << 2); // pin + // One full-duplex transaction: command out, 12-bit result back in bytes 1 and 2. + // Word aligned so ESP-IDF DMA uses the buffer in place; only ESP32-P4 also checks the length and bounces. + alignas(4) uint8_t buffer[3] = {command, 0x00, 0x00}; this->enable(); - this->transfer_byte(command); - b0 = this->transfer_byte(0x00); - b1 = this->transfer_byte(0x00); + this->transfer_array(buffer, sizeof(buffer)); this->disable(); - uint16_t digital_value = encode_uint16(b0, b1) >> 4; + uint16_t digital_value = encode_uint16(buffer[1], buffer[2]) >> 4; return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } From f70880ce9620443d3b70a8ffe7b51642df1aa29f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:53:09 -0400 Subject: [PATCH 453/759] Bump astral-sh/setup-uv from 10.1.0 to 10.2.0 (#19594) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 c4c1ab072ff..d0bd1de9ef1 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08cd852f022..2343f8c5baf 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -413,7 +413,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1274,7 +1274,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84d5e229d91..a0725374f92 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 4ccc5fa37e5e0755acf5babef7642aabcfa20594 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:53:25 -0400 Subject: [PATCH 454/759] Bump astral-sh/setup-uv from 10.1.0 to 10.2.0 in /.github/actions/restore-python (#19593) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 fa42372ac81..affe8fc2d6b 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From 73ca04579cf54d1eb6192c39cae9949c24b6f493 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:53:46 -0400 Subject: [PATCH 455/759] Update pyparsing requirement from >=3.3.2 to >=3.3.3 (#19592) 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 c810755817f..f678a9e5e9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this -pyparsing >= 3.3.2 +pyparsing >= 3.3.3 # For autocompletion argcomplete>=3.7.2 From 3ef0acd4b21561933569267704b103354ecf6197 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Thu, 24 Sep 2026 14:54:58 -0700 Subject: [PATCH 456/759] [sgp30][sgp4x] Migrate CONF_HUMIDITY_SOURCE to const.py (#19436) --- esphome/components/const/__init__.py | 1 + esphome/components/sgp30/sensor.py | 2 +- esphome/components/sgp4x/sensor.py | 8 +++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 31b0a0c097e..b8bb9db8bf5 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -23,6 +23,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_IS_WRGB = "is_wrgb" diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index beb549ce706..75244abee80 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_HUMIDITY_SOURCE import esphome.config_validation as cv from esphome.const import ( CONF_BASELINE, @@ -31,7 +32,6 @@ SGP30Component = sgp30_ns.class_( CONF_ECO2_BASELINE = "eco2_baseline" CONF_TVOC_BASELINE = "tvoc_baseline" CONF_UPTIME = "uptime" -CONF_HUMIDITY_SOURCE = "humidity_source" CONFIG_SCHEMA = ( diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1a88d190e4f..bf8fbe1a975 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -1,6 +1,10 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor -from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX +from esphome.components.const import ( + CONF_HUMIDITY_SOURCE, + CONF_NOX_INDEX, + CONF_VOC_INDEX, +) import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -33,8 +37,6 @@ SGP4xComponent = sgp4x_ns.class_( sensirion_common.SensirionI2CDevice, ) -CONF_HUMIDITY_SOURCE = "humidity_source" - def validate_sensors(config: ConfigType) -> ConfigType: if CONF_VOC_INDEX not in config and CONF_NOX_INDEX not in config: From 253793c4df4bf96c22f8161a18bf824f073ed5d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 23:56:28 +0100 Subject: [PATCH 457/759] [web_server] Mask the value of a password text entity, not only its state (#19385) --- esphome/components/web_server/web_server.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 4906a2050ae..6e48e2700f7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1413,8 +1413,11 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st json::JsonBuilder builder; JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), + start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); From 4a2f17bc8ba30bad7143c29c086425af5dd79057 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 00:24:34 +0100 Subject: [PATCH 458/759] [noise] Run the ESP8266 at 160 MHz during the handshake (#19233) --- esphome/components/noise/noise_handshake.cpp | 2 + .../uart/uart_component_esp8266.cpp | 49 ++++++++++++------- .../components/uart/uart_component_esp8266.h | 6 +-- esphome/core/helpers.h | 26 ++++++++++ 4 files changed, 63 insertions(+), 20 deletions(-) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index cc7fa603c4e..64846dcb678 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -95,6 +95,7 @@ NoiseResponderHandshake::Action NoiseResponderHandshake::action() const { } int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_input(mbuf, data, len); @@ -103,6 +104,7 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_output(mbuf, out, capacity); diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 2f8b4dbd117..1d492fce476 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP8266 #include "uart_component_esp8266.h" +#include #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -251,23 +252,35 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } } +// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles +// the bit time then. The whole byte is read inside the ISR, so the clock cannot change partway through. +__attribute__((always_inline)) static inline uint32_t rx_bit_time(uint32_t bit_time) { +#if F_CPU != 160000000L + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- CPU2X is MMIO at a fixed address + return bit_time << (CPU2X & 1); +#else + return bit_time; +#endif +} + void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { - uint32_t wait = arg->bit_time_ + arg->bit_time_ / 3 - 500; + const uint32_t bit_time = rx_bit_time(arg->bit_time_); + uint32_t wait = bit_time + bit_time / 3 - 500; const uint32_t start = arch_get_cpu_cycle_count(); uint8_t rec = 0; // Manually unroll the loop for (int i = 0; i < arg->data_bits_; i++) - rec |= arg->read_bit_(&wait, start) << i; + rec |= arg->read_bit_(&wait, start, bit_time) << i; /* If parity is enabled, just read it and ignore it. */ /* TODO: Should we check parity? Or is it too slow for nothing added..*/ if (arg->parity_ == UART_CONFIG_PARITY_EVEN || arg->parity_ == UART_CONFIG_PARITY_ODD) - arg->read_bit_(&wait, start); + arg->read_bit_(&wait, start, bit_time); // Stop bit - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); if (arg->stop_bits_ == 2) - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); arg->rx_buffer_[arg->rx_in_pos_] = rec; arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; @@ -296,37 +309,39 @@ void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { } { + // Transmit runs from the main loop and never overlaps a CpuFrequencyBoost InterruptLock lock; - uint32_t wait = this->bit_time_; + const uint32_t bit_time = this->bit_time_; + uint32_t wait = bit_time; const uint32_t start = arch_get_cpu_cycle_count(); // Start bit - this->write_bit_(false, &wait, start); + this->write_bit_(false, &wait, start, bit_time); for (int i = 0; i < this->data_bits_; i++) { bool bit = data & (1 << i); - this->write_bit_(bit, &wait, start); + this->write_bit_(bit, &wait, start, bit_time); if (need_parity_bit) parity_bit ^= bit; } if (need_parity_bit) - this->write_bit_(parity_bit, &wait, start); + this->write_bit_(parity_bit, &wait, start, bit_time); // Stop bit - this->write_bit_(true, &wait, start); + this->write_bit_(true, &wait, start, bit_time); if (this->stop_bits_ == 2) - this->wait_(&wait, start); + this->wait_(&wait, start, bit_time); } } -void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { while (arch_get_cpu_cycle_count() - start < *wait) ; - *wait += this->bit_time_; + *wait += bit_time; } -bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start) { - this->wait_(wait, start); +bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + this->wait_(wait, start, bit_time); return this->rx_pin_.digital_read(); } -void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time) { this->tx_pin_.digital_write(bit); - this->wait_(wait, start); + this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { if (this->rx_in_pos_ == this->rx_out_pos_) diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 469885b6b6c..54bcad39933 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -28,9 +28,9 @@ class ESP8266SoftwareSerial { protected: static void gpio_intr(ESP8266SoftwareSerial *arg); - void wait_(uint32_t *wait, const uint32_t &start); - bool read_bit_(uint32_t *wait, const uint32_t &start); - void write_bit_(bool bit, uint32_t *wait, const uint32_t &start); + void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); uint32_t bit_time_{0}; uint8_t *rx_buffer_{nullptr}; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cfc92932a9d..13ba0a9791c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2066,6 +2066,32 @@ class LwIPLock { #endif }; +#if defined(USE_ESP8266) && F_CPU != 160000000L +// Forward decl from +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" bool system_update_cpu_freq(uint8_t freq); +#endif + +/** Runs the CPU at 160 MHz while alive. ESP8266 built for 80 MHz only; elsewhere it compiles to nothing. + * + * The core resets the clock before every loop() pass, so a scope must stay within one pass, must not nest and + * must not yield to the main loop. Peripheral clocks are unchanged, but the cycle counter runs twice as fast, so + * code that times itself against F_CPU, including ISRs that fire while a scope is open, must read CPU2X. + */ +class CpuFrequencyBoost { + public: + CpuFrequencyBoost(const CpuFrequencyBoost &) = delete; + CpuFrequencyBoost &operator=(const CpuFrequencyBoost &) = delete; +#if defined(USE_ESP8266) && F_CPU != 160000000L + CpuFrequencyBoost() { system_update_cpu_freq(160); } + ~CpuFrequencyBoost() { system_update_cpu_freq(80); } +#else + // Not = default, so clang-tidy does not flag unused variables at call sites + CpuFrequencyBoost() {} + ~CpuFrequencyBoost() {} +#endif +}; + /** Helper class to request `loop()` to be called as fast as possible. * * Usually the ESPHome main loop runs at 60 Hz, sleeping in between invocations of `loop()` if necessary. When a higher From 8db901cbfccb50aed0675733574e368d1d2eafb4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 25 Sep 2026 05:00:33 +0100 Subject: [PATCH 459/759] [nrf52] set WDT timeout to 30sec for Adafruit bootloader (#19596) Co-authored-by: J. Nick Koston --- esphome/components/nrf52/dfu.cpp | 1 + esphome/components/zephyr/hal.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 24dee997269..0122ed9b31b 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c3..10e8340a409 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -27,7 +27,11 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else From 91210c5ef543878ff6c685bcc5b1d1ba7d981018 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 08:37:03 +0100 Subject: [PATCH 460/759] [button] Use register_apply_action for button.press (#19598) --- esphome/components/button/__init__.py | 18 ++++-------------- esphome/components/button/automation.h | 19 ------------------- tests/components/button/common.yaml | 6 ++++++ 3 files changed, 10 insertions(+), 33 deletions(-) delete mode 100644 esphome/components/button/automation.h diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ee24002b8a3..bdb438a99de 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,14 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -40,8 +40,6 @@ button_ns = cg.esphome_ns.namespace("button") Button = button_ns.class_("Button", cg.EntityBase) ButtonPtr = Button.operator("ptr") -PressAction = button_ns.class_("PressAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -123,17 +121,9 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True +automation.register_apply_action( + "button.press", BUTTON_PRESS_SCHEMA, automation.ApplyCall("press()") ) -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) diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h deleted file mode 100644 index 683328b394c..00000000000 --- a/esphome/components/button/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/components/button/button.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::button { - -template class PressAction final : public Action { - public: - explicit PressAction(Button *button) : button_(button) {} - - void play(const Ts &...x) override { this->button_->press(); } - - protected: - Button *button_; -}; - -} // namespace esphome::button diff --git a/tests/components/button/common.yaml b/tests/components/button/common.yaml index d5978601f43..352a4c29234 100644 --- a/tests/components/button/common.yaml +++ b/tests/components/button/common.yaml @@ -4,3 +4,9 @@ button: id: some_button on_press: - logger.log: Button pressed + +esphome: + on_boot: + - button.press: some_button + - button.press: + id: some_button From cc5ed932471da138e010b21a426f28ff8d698fba Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:29:16 +0200 Subject: [PATCH 461/759] [zigbee] move init back into setup (#19427) --- esphome/components/zigbee/zigbee_esp32.cpp | 34 +++++++++++++++++--- esphome/components/zigbee/zigbee_esp32.h | 36 ++++++++++++++++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 4f9c70da75c..65ceae8a4bf 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -272,7 +272,9 @@ static void ezb_task(void *pv_parameters) { vTaskDelete(NULL); } -ZigbeeComponent::ZigbeeComponent() { +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zigbee_platform_config_t platform_config = { .storage_partition_name = "nvs", .radio_config = EZB_DEFAULT_RADIO_CONFIG(), @@ -299,11 +301,7 @@ ZigbeeComponent::ZigbeeComponent() { this->mark_failed(); return; } - this->dev_desc_ = ezb_af_create_device_desc(); -} -void ZigbeeComponent::setup() { - global_zigbee = this; #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); @@ -341,6 +339,15 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); + // Finish zigbee data model + for (auto &attr_value : this->attr_values_) { + ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; + void *value_p = &attr_value.value; + ezb_zcl_attr_desc_set_value(attr_desc, value_p); + } + // free memory + std::vector().swap(this->attr_values_); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -383,6 +390,23 @@ void ZigbeeComponent::dump_config() { YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } + +bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return false; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return false; + } + if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { + return false; + } + ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, + endpoint_id, cluster_id); + return true; +} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index c19fc3ad63e..e84ed9817cd 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -30,11 +30,15 @@ static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); +struct AttrValue { + ezb_zcl_attr_desc_t attr_desc; + uint8_t value[4]; +}; + class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: - ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; @@ -88,7 +92,8 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - ezb_af_device_desc_t dev_desc_; + std::vector attr_values_; + ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; LazyCallbackManager start_cb_{}; bool start_reported_{false}; @@ -96,6 +101,8 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; + // TODO: remove when esp-zigbee-lib fixes set_value before init + bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -110,10 +117,22 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -133,7 +152,18 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); + // TODO: revert when esp-zigbee-lib fixes set_value before init + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + + if (attr_desc != NULL) { + static_assert(sizeof(*value_p) <= 4); + AttrValue attr_value; + attr_value.attr_desc = attr_desc; + memcpy(&attr_value.value, value_p, sizeof(*value_p)); + attr_values_.push_back(attr_value); + } else { + esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); + } if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; From 6fa56e18bd9e54d30ee9855f78fa77f72cfa3c84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:18:52 +0100 Subject: [PATCH 462/759] [sendspin] Use register_apply_action for the switch, static delay and transition_finished actions (#19608) --- esphome/components/sendspin/__init__.py | 25 ++-------- esphome/components/sendspin/automation.h | 25 ---------- esphome/components/sendspin/image/__init__.py | 22 +-------- .../components/sendspin/image/automation.h | 20 -------- .../sendspin/media_source/__init__.py | 49 +++++-------------- .../sendspin/media_source/automations.h | 26 ---------- esphome/components/sendspin/sendspin_hub.cpp | 7 +++ esphome/components/sendspin/sendspin_hub.h | 3 ++ .../sendspin/common-media_source.yaml | 6 +++ 9 files changed, 35 insertions(+), 148 deletions(-) delete mode 100644 esphome/components/sendspin/automation.h delete mode 100644 esphome/components/sendspin/image/automation.h delete mode 100644 esphome/components/sendspin/media_source/automations.h diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 49bee109361..42dae464ede 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -21,8 +21,8 @@ from esphome.const import ( CONF_VERSION, CONF_WIDTH, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType # mdns for autodiscovery @@ -109,13 +109,6 @@ SendspinHub = sendspin_ns.class_( ) -SendspinSwitchCommandAction = sendspin_ns.class_( - "SendspinSwitchCommandAction", - automation.Action, - cg.Parented.template(SendspinHub), -) - - @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -240,21 +233,11 @@ SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "sendspin.switch", - SendspinSwitchCommandAction, SENDSPIN_SIMPLE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("switch_client()"), ) -async def sendspin_switch_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: ConfigType) -> None: diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h deleted file mode 100644 index 0b408b12350..00000000000 --- a/esphome/components/sendspin/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "sendspin_hub.h" - -namespace esphome::sendspin_ { - -#ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); - } -}; -#endif // USE_SENDSPIN_CONTROLLER - -} // namespace esphome::sendspin_ - -#endif // USE_ESP32 diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 3c6c82b0091..e26e8204aaf 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -16,7 +16,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import ID -from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType from .. import ( @@ -198,16 +197,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -SendspinImageTransitionFinishedAction = sendspin_ns.class_( - "SendspinImageTransitionFinishedAction", - automation.Action, - cg.Parented.template(SendspinImageSlot), -) - - -@automation.register_action( +automation.register_apply_action( "sendspin.image.transition_finished", - SendspinImageTransitionFinishedAction, automation.maybe_simple_id( cv.Schema( { @@ -215,14 +206,5 @@ SendspinImageTransitionFinishedAction = sendspin_ns.class_( } ) ), - synchronous=True, + automation.ApplyCall("transition_finished()"), ) -async def sendspin_image_transition_finished_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h deleted file mode 100644 index 154e62a4b20..00000000000 --- a/esphome/components/sendspin/image/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) - -#include "esphome/core/automation.h" -#include "sendspin_image.h" - -namespace esphome::sendspin_ { - -template -class SendspinImageTransitionFinishedAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->transition_finished(); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6a9f1f18ba2..743d0bb62cf 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -8,8 +8,6 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TASK_STACK_IN_PSRAM, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( @@ -41,18 +39,6 @@ SendspinMediaSource = sendspin_ns.class_( media_source.MediaSource, ) -EnableStaticDelayAdjustmentAction = sendspin_ns.class_( - "EnableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) - -DisableStaticDelayAdjustmentAction = sendspin_ns.class_( - "DisableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) - def _resolve_codecs(config: ConfigType) -> ConfigType: """Validate the codec preference list, filling in the default when it is not set.""" @@ -144,25 +130,16 @@ SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) - -@automation.register_action( - "sendspin.media_source.enable_static_delay_adjustment", - EnableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sendspin.media_source.disable_static_delay_adjustment", - DisableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -async def sendspin_static_delay_adjustment_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 +for _name, _call in ( + ( + "sendspin.media_source.enable_static_delay_adjustment", + "set_static_delay_adjustable(true)", + ), + ( + "sendspin.media_source.disable_static_delay_adjustment", + "set_static_delay_adjustable(false)", + ), +): + automation.register_apply_action( + _name, SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h deleted file mode 100644 index f5c35f107af..00000000000 --- a/esphome/components/sendspin/media_source/automations.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) - -#include "esphome/core/automation.h" -#include "sendspin_media_source.h" - -namespace esphome::sendspin_ { - -template -class EnableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } -}; - -template -class DisableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 58ec57c7681..15d358d50fb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -284,6 +284,13 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) +void SendspinHub::switch_client() { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); +} + // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index b00fdc436eb..8ab70d61ef1 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -181,6 +181,9 @@ class SendspinHub final : public Component, void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); + /// @brief Sends the SWITCH controller command; exposed as the sendspin.switch action. + void switch_client(); + template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..87dfb0add6f 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,9 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + +wifi: + on_connect: + then: + - sendspin.media_source.enable_static_delay_adjustment: media_source_id + - sendspin.media_source.disable_static_delay_adjustment: media_source_id From 28343e1bc9efe5d0eaf95dc4531c7a6517719158 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:20:13 +0100 Subject: [PATCH 463/759] [mqtt] Use register_apply_action for mqtt.publish, mqtt.enable and mqtt.disable (#19602) --- esphome/components/mqtt/__init__.py | 75 +++++++++++---------------- esphome/components/mqtt/mqtt_client.h | 37 ------------- tests/components/mqtt/common.yaml | 7 +++ 3 files changed, 36 insertions(+), 83 deletions(-) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index f4be6843180..37694fcfc54 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -117,10 +117,7 @@ mqtt_ns = cg.esphome_ns.namespace("mqtt") MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) -MQTTPublishAction = mqtt_ns.class_("MQTTPublishAction", automation.Action) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTEnableAction = mqtt_ns.class_("MQTTEnableAction", automation.Action) -MQTTDisableAction = mqtt_ns.class_("MQTTDisableAction", automation.Action) MQTTMessageTrigger = mqtt_ns.class_( "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component ) @@ -503,22 +500,26 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True -) -async def mqtt_publish_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TOPIC], args, cg.std_string) - cg.add(var.set_topic(template_)) +# A bare literal is ambiguous between the std::string and (const char *, size_t) publish +# overloads, so constants and inlined `return "...";` lambdas are both spelled as std::string. +def _std_string(config: ConfigType, value: str) -> str: + rendered = automation.flash_string(config, value) + return rendered if CORE.is_esp8266 else f"std::string({rendered})" - template_ = await cg.templatable(config[CONF_PAYLOAD], args, cg.std_string) - cg.add(var.set_payload(template_)) - template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) - cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) - cg.add(var.set_retain(template_)) - return var + +automation.register_apply_action( + "mqtt.publish", + MQTT_PUBLISH_ACTION_SCHEMA, + automation.ApplyCall( + "publish({}, {}, {}, {})", + ( + (CONF_TOPIC, "std::string", _std_string), + (CONF_PAYLOAD, "std::string", _std_string), + (CONF_QOS, cg.uint8), + (CONF_RETAIN, cg.bool_), + ), + ), +) MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema( @@ -608,34 +609,16 @@ automation.register_apply_condition( ) -@automation.register_action( - "mqtt.enable", - MQTTEnableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_enable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "mqtt.disable", - MQTTDisableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_disable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in (("mqtt.enable", "enable()"), ("mqtt.disable", "disable()")): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.GenerateID(): cv.use_id(MQTTClientComponent), + } + ), + automation.ApplyCall(_call), + ) _platform_filter = filter_source_files_from_platform( diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 2fd9a5a5502..ced9c84e100 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -378,23 +378,6 @@ class MQTTDisconnectTrigger final : public Trigger { } }; -template class MQTTPublishAction final : public Action { - public: - MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, topic) - TEMPLATABLE_VALUE(std::string, payload) - TEMPLATABLE_VALUE(uint8_t, qos) - TEMPLATABLE_VALUE(bool, retain) - - void play(const Ts &...x) override { - this->parent_->publish(this->topic_.value(x...), this->payload_.value(x...), this->qos_.value(x...), - this->retain_.value(x...)); - } - - protected: - MQTTClientComponent *parent_; -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -417,26 +400,6 @@ template class MQTTPublishJsonAction final : public Action class MQTTEnableAction final : public Action { - public: - MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->enable(); } - - protected: - MQTTClientComponent *parent_; -}; - -template class MQTTDisableAction final : public Action { - public: - MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->disable(); } - - protected: - MQTTClientComponent *parent_; -}; - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index a1d27cdbd52..5ed997bfedb 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -101,6 +101,13 @@ button: payload: Hello qos: 2 retain: true + - mqtt.publish: + topic: !lambda return "some/topic/button/lambda"; + payload: !lambda |- + std::string payload = "Hello"; + return payload; + qos: !lambda return 1; + retain: !lambda return false; climate: - platform: thermostat From 898ab7c7011df90a1dd25ec7529de504f2237dc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:21:18 +0100 Subject: [PATCH 464/759] [safe_mode] Use register_apply_action for safe_mode.mark_successful (#19605) --- esphome/components/safe_mode/__init__.py | 20 ++++--------------- esphome/components/safe_mode/automation.h | 12 ----------- .../components/safe_mode/common-enabled.yaml | 3 +++ 3 files changed, 7 insertions(+), 28 deletions(-) delete mode 100644 esphome/components/safe_mode/automation.h diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 9bc8a263c82..5d03ed70a83 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,8 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import RawExpression from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -22,7 +22,6 @@ CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) -MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) def _remove_id_if_disabled(value: ConfigType) -> ConfigType: @@ -53,26 +52,15 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "safe_mode.mark_successful", - MarkSuccessfulAction, cv.Schema( { cv.GenerateID(): cv.use_id(SafeModeComponent), } ), - synchronous=True, + automation.ApplyCall("mark_successful()"), ) -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)) - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h deleted file mode 100644 index e2858dff341..00000000000 --- a/esphome/components/safe_mode/automation.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include "esphome/core/automation.h" -#include "safe_mode.h" - -namespace esphome::safe_mode { - -template class MarkSuccessfulAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->mark_successful(); } -}; - -} // namespace esphome::safe_mode diff --git a/tests/components/safe_mode/common-enabled.yaml b/tests/components/safe_mode/common-enabled.yaml index 43025c60dbd..b2a10b6d93f 100644 --- a/tests/components/safe_mode/common-enabled.yaml +++ b/tests/components/safe_mode/common-enabled.yaml @@ -3,6 +3,7 @@ wifi: password: password1 safe_mode: + id: safe_mode_1 boot_is_good_after: 2min num_attempts: 3 reboot_timeout: 2min @@ -20,3 +21,5 @@ switch: esphome: on_boot: - safe_mode.mark_successful + - safe_mode.mark_successful: + id: safe_mode_1 From f7dd9c21c5919d9be36e2c0cce801bf0000e1d44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:21:47 +0100 Subject: [PATCH 465/759] [lock] Use register_apply_action for lock.lock, lock.unlock and lock.open (#19601) --- esphome/components/lock/__init__.py | 32 +++++++++------------------- esphome/components/lock/automation.h | 30 -------------------------- tests/components/lock/common.yaml | 3 ++- 3 files changed, 12 insertions(+), 53 deletions(-) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 4ceb6206fd9..aa5bec98d53 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,13 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -29,9 +29,6 @@ Lock = lock_ns.class_("Lock", cg.EntityBase) LockPtr = Lock.operator("ptr") LockCall = lock_ns.class_("LockCall") -UnlockAction = lock_ns.class_("UnlockAction", automation.Action) -LockAction = lock_ns.class_("LockAction", automation.Action) -OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) LockStateForwarder = lock_ns.class_("LockStateForwarder") @@ -134,23 +131,14 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True -) -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) +for _name, _call in ( + ("lock.unlock", "unlock()"), + ("lock.lock", "lock()"), + ("lock.open", "open()"), +): + automation.register_apply_action( + _name, LOCK_ACTION_SCHEMA, automation.ApplyCall(_call) + ) automation.register_apply_condition( diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 13153bc1df0..e578a056439 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,36 +6,6 @@ namespace esphome::lock { -template class LockAction final : public Action { - public: - explicit LockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->lock(); } - - protected: - Lock *lock_; -}; - -template class UnlockAction final : public Action { - public: - explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->unlock(); } - - protected: - Lock *lock_; -}; - -template class OpenAction final : public Action { - public: - explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->open(); } - - protected: - Lock *lock_; -}; - /// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. template struct LockStateForwarder { diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 2b360ce3fbf..397130dee5d 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -3,7 +3,8 @@ esphome: then: - lock.lock: test_lock1 - lock.unlock: test_lock1 - - lock.open: test_lock1 + - lock.open: + id: test_lock1 - if: condition: lock.is_locked: test_lock1 From a17285afc799fec4932cfe26ae96afb4890a7aa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:23:03 +0100 Subject: [PATCH 466/759] [fan] Use register_apply_action for fan.toggle and fan.turn_off (#19600) --- esphome/components/fan/__init__.py | 19 ++----------------- esphome/components/fan/automation.h | 18 ------------------ tests/components/fan/common.yaml | 3 +++ 3 files changed, 5 insertions(+), 35 deletions(-) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index d00375ae960..526499fe077 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -61,8 +61,6 @@ RESTORE_MODES = { } # Actions -TurnOffAction = fan_ns.class_("TurnOffAction", automation.Action) -ToggleAction = fan_ns.class_("ToggleAction", automation.Action) CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) FanStateTrigger = fan_ns.class_( @@ -310,21 +308,8 @@ FAN_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - +automation.register_apply_action("fan.toggle", FAN_ACTION_SCHEMA, call="toggle") +automation.register_apply_action("fan.turn_off", FAN_ACTION_SCHEMA, call="turn_off") automation.register_apply_action( "fan.turn_on", diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 5f0843581bc..4c587904b73 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,24 +6,6 @@ namespace esphome::fan { -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->turn_off().perform(); } - - Fan *state_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->toggle().perform(); } - - Fan *state_; -}; - template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 4867804394a..12c7b6c8726 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -104,6 +104,9 @@ button: fan.is_on: test_fan then: - fan.turn_off: test_fan + - fan.toggle: test_fan + - fan.turn_off: + id: test_fan - if: condition: fan.is_off: From 3fb48e0bfa724fc2d0150a2c9f8db1f3c585ed90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:23:33 +0100 Subject: [PATCH 467/759] [script] Use register_apply_action for script.stop (#19599) --- esphome/components/script/__init__.py | 10 ++-------- esphome/components/script/script.h | 10 ---------- tests/components/script/common.yaml | 2 ++ 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 6c16d002ad0..2c79e9ed0a1 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -9,7 +9,6 @@ CODEOWNERS = ["@esphome/core"] script_ns = cg.esphome_ns.namespace("script") Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) -ScriptStopAction = script_ns.class_("ScriptStopAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) @@ -206,16 +205,11 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_apply_action( "script.stop", - ScriptStopAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def script_stop_action_to_code(config, action_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index d83555016de..b9ea75e0bcf 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -257,16 +257,6 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction final : public Action { - public: - ScriptStopAction(C *script) : script_(script) {} - - void play(const Ts &...x) override { this->script_->stop(); } - - protected: - C *script_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f4818e22962..6b7290277a1 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -15,6 +15,8 @@ esphome: strings: ["a", "b"] - script.wait: my_script - script.stop: my_script + - script.stop: + id: my_script_queued - if: condition: - script.is_running: my_script From 370d76d01379cc53a10107c9945e933862f5fd0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:26:57 +0100 Subject: [PATCH 468/759] [deep_sleep] Use register_apply_action for deep_sleep.prevent and deep_sleep.allow (#19607) --- esphome/components/deep_sleep/__init__.py | 40 +++++-------------- .../deep_sleep/deep_sleep_component.h | 13 ------ .../deep_sleep/common-esp32-all.yaml | 1 + .../deep_sleep/common-esp32-ext1.yaml | 1 + tests/components/deep_sleep/common-esp32.yaml | 1 + tests/components/deep_sleep/common.yaml | 4 ++ .../test-ota-rollback.nrf52-mcumgr.yaml | 1 + .../deep_sleep/test.bk72xx-ard.yaml | 1 + .../deep_sleep/test.esp8266-ard.yaml | 1 + .../deep_sleep/test.nrf52-adafruit.yaml | 1 + 10 files changed, 20 insertions(+), 44 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index d5d70775834..06286933b90 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -214,16 +214,6 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) -PreventDeepSleepAction = deep_sleep_ns.class_( - "PreventDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) -AllowDeepSleepAction = deep_sleep_ns.class_( - "AllowDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") WAKEUP_PIN_MODES = { @@ -490,27 +480,15 @@ async def deep_sleep_enter_to_code( return var -@automation.register_action( - "deep_sleep.prevent", - PreventDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -@automation.register_action( - "deep_sleep.allow", - AllowDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -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 +for _name, _call in ( + ("deep_sleep.prevent", "prevent_deep_sleep()"), + ("deep_sleep.allow", "allow_deep_sleep()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + automation.ApplyCall(_call), + ) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 0bbca4c5c4d..2afe19c0698 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -121,8 +121,6 @@ class Ext1WakeTrigger : public Trigger<>, public Component { template class EnterDeepSleepAction; -template class PreventDeepSleepAction; - /** This component allows setting up the node to go into deep sleep mode to conserve battery. * * To set this component up, first set *when* the deep sleep should trigger using set_run_cycles @@ -298,15 +296,4 @@ template class EnterDeepSleepAction final : public Action #endif }; -template -class PreventDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } -}; - -template class AllowDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } -}; - } // namespace esphome::deep_sleep diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index 9dc2f872588..68ef8f7b584 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index c531d447438..e22436dd648 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index e670787cc0b..248f527616d 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index 7a1a709965b..6c6222ff875 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,6 +4,10 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - deep_sleep.prevent: + id: deep_sleep_1 + - deep_sleep.allow: + id: deep_sleep_1 - if: condition: lambda: 'return false;' diff --git a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml index 485490576d8..b59b927e0a1 100644 --- a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml +++ b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml @@ -6,6 +6,7 @@ packages: deep_sleep: !include common.yaml deep_sleep: + id: deep_sleep_1 run_duration: 10s zephyr_ble_server: diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index bdbd27c902e..df0c85d7088 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 30s sleep_duration: 12h on_wake: diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index e4c592c0951..8e5b1ca4f72 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s on_wake: diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml index 6362142be2e..bfc89303a93 100644 --- a/tests/components/deep_sleep/test.nrf52-adafruit.yaml +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s From 2d8e43ef7805bde7c7a2ca74422c1ca2c954a8de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:42:52 +0100 Subject: [PATCH 469/759] [ota] Add allow_plaintext_upload for devices that never migrated to encryption (#19396) --- esphome/__main__.py | 5 ++ esphome/components/esphome/ota/__init__.py | 45 ++++++++-- esphome/espota2.py | 47 ++++++++-- tests/component_tests/ota/test_esphome_ota.py | 36 +++++++- .../test_esphome_ota_migration_password.yaml | 14 +++ .../ota/allow_plaintext_upload.yaml | 13 +++ ...te-allow_plaintext_upload.esp8266-ard.yaml | 2 + tests/unit_tests/test_espota2_noise.py | 85 ++++++++++++++++++- tests/unit_tests/test_main.py | 82 +++++++++++++++++- 9 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/ota/test_esphome_ota_migration_password.yaml create mode 100644 tests/components/ota/allow_plaintext_upload.yaml create mode 100644 tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 30e97f55eb4..d68319e63a7 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1343,8 +1343,12 @@ def _upload_via_native_api( # fall back to a plaintext upload noise_psk = None plaintext_fallback = False + allow_plaintext_upload = False if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) + allow_plaintext_upload = bool( + encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) + ) if not noise_psk: raise EsphomeError( "OTA encryption is configured but no key was resolved; " @@ -1395,6 +1399,7 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bace13c17fc..e14f5e7e86e 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - encryption_schema, + ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, ) @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv from esphome.types import ConfigType @@ -231,15 +232,38 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - encryption_conf[CONF_KEY] = api_key +# Uploader side options live only on the ota block; the api block keeps the +# shared schema +_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( + { + cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, + } +) + + +def _encryption_schema(config: ConfigType | None) -> ConfigType: + # Only a bare `encryption:` block is keyless; `false` or a list must fail + return _ENCRYPTION_SCHEMA({} if config is None else config) + + # Also called on merged same-port configs in final validate, where schemas # do not run def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: - if CONF_PASSWORD in config and CONF_ENCRYPTION in config: - raise cv.Invalid( - f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " - f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" - ) - return config + if ( + CONF_PASSWORD not in config + or (encryption := config.get(CONF_ENCRYPTION)) is None + ): + return config + # The migration install may still have to answer the old firmware's + # password prompt on the plaintext leg; the password is not built in + if encryption.get(CONF_ALLOW_PLAINTEXT_UPLOAD): + return config + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}' " + f"(or set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' for the one install that " + f"migrates a device still asking for it)" + ) def _consume_ota_sockets(config: ConfigType) -> ConfigType: @@ -269,7 +293,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), - cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(CONF_ENCRYPTION): _encryption_schema, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), @@ -304,7 +328,10 @@ async def to_code(config: ConfigType) -> None: # An empty password opts in to the auth code path so set_auth_password() can be # called at runtime (e.g. to rotate the password from a lambda). When `password:` # is omitted entirely, the auth path is excluded to save flash on small devices. - if CONF_PASSWORD in config: + # A password is never built in next to encryption: validation only lets + # the two coexist for the migration install, where the password answers + # the running firmware and the build is authenticated by the key + if CONF_PASSWORD in config and CONF_ENCRYPTION not in config: cg.add_define("USE_OTA_PASSWORD") if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) diff --git a/esphome/espota2.py b/esphome/espota2.py index c683ffa323c..952f88fc4b6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -211,6 +211,27 @@ class OTAEncryptionFallback(OTAError): """The encrypted attempt failed and the caller may retry in plaintext.""" +# Uploader side option under `ota: encryption:`; the ota component imports the +# name so the upload path never loads the component module +CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( + f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " + "migrates a device which never encrypted. If this device encrypted before, " + "something on the network stripped the offer: remove the option and check " + "the network." +) +# Logged only once the device is seen encrypting, so the migration install +# itself is never nagged and the user learns exactly when removal is safe +ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING = f""" +****************************************************************** +* This device offers OTA encryption and accepted the key, so +* '{CONF_ALLOW_PLAINTEXT_UPLOAD}' under 'ota: encryption:' has done +* its job. Remove it from the configuration now, together with +* any 'password:' on that block: leaving the option in place lets +* an attacker on the network strip the encryption offer and +* downgrade a future upload to plaintext. +******************************************************************""" + # Remove before 2027.3.0 PLAINTEXT_FALLBACK_NOTICE = ( "A device with an api encryption key offers encryption after this " @@ -512,6 +533,7 @@ def perform_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -577,25 +599,32 @@ def perform_ota( features = 0 if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): - if plaintext_fallback: - # Remove before 2027.3.0: older firmware that cannot encrypt still - # gets its update on this connection + # Remove before 2027.3.0: drop `or plaintext_fallback` and + # PLAINTEXT_FALLBACK_NOTICE here; allow_plaintext_upload stays + if allow_plaintext_upload or plaintext_fallback: + # The running firmware cannot encrypt; it still gets this update, + # and the build being sent offers encryption for the next one _LOGGER.warning( "The device did not offer OTA encryption; continuing in plaintext. %s", - PLAINTEXT_FALLBACK_NOTICE, + ALLOW_PLAINTEXT_UPLOAD_NOTICE + if allow_plaintext_upload + else PLAINTEXT_FALLBACK_NOTICE, ) noise_psk = None else: # Fail closed: an attacker could otherwise strip the offer and # capture the image (wifi credentials, api key) + # Remove before 2027.3.0: installing without the block no longer + # falls back then; advise 'allow_plaintext_upload: true' instead raise OTAError( "An OTA encryption key is configured but the device did not " "offer encryption; refusing to send the image in plaintext. " "The running firmware predates ESPHome 2026.9.0 or has no " "'api: encryption: key'. With an api key, install once " "without the 'ota: encryption:' block (that build offers " - "encryption), then restore it; otherwise flash by serial or " - "the web_server OTA platform." + f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " + "true' under 'ota: encryption:' for this one install, or flash by " + "serial or the web_server OTA platform." ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -619,6 +648,8 @@ def perform_ota( raise OTAEncryptionFallback(str(err)) from err raise _LOGGER.info("Encrypted connection established") + if allow_plaintext_upload: + _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -824,6 +855,7 @@ def run_ota_impl_( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -899,6 +931,7 @@ def run_ota_impl_( ota_type, encryption.noise_psk, encryption.plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) except OTAEncryptionFallback as err: # Same address and attempt budget: not a network retry @@ -940,6 +973,7 @@ def run_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -950,6 +984,7 @@ def run_ota( ota_type, noise_psk, plaintext_fallback, + allow_plaintext_upload, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d8bcd7e2758..cd66503c1bb 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -12,10 +12,11 @@ from esphome import config_validation as cv from esphome.components.esphome.ota import ( AUTO_LOAD, FILTER_SOURCE_FILES, + _encryption_schema, _validate_no_password_with_encryption, ota_esphome_final_validate, ) -from esphome.components.noise import static_encryption_key +from esphome.components.noise import encryption_schema, static_encryption_key from esphome.const import ( CONF_API, CONF_ENCRYPTION, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv @@ -198,6 +200,21 @@ def test_encryption_without_any_key_rejected() -> None: fv.full_config.reset(token) +def test_encryption_schema_allow_plaintext_upload() -> None: + """The opt in is an ota only option with no default, so a merged block + that does not mention it cannot clear it; the shared api schema does not + know it.""" + assert _encryption_schema(None) == {} + assert _encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) == { + CONF_ALLOW_PLAINTEXT_UPLOAD: True + } + assert _encryption_schema({CONF_KEY: API_KEY}) == {CONF_KEY: API_KEY} + with pytest.raises(cv.Invalid): + _encryption_schema(False) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -489,6 +506,13 @@ def test_static_encryption_key() -> None: {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), + # The migration install keeps the password for the old firmware's + # prompt but the build it sends is authenticated by the key alone + ( + "migration_password", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, + {"USE_OTA_PASSWORD", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", @@ -549,6 +573,16 @@ def test_password_with_encryption_rejected() -> None: _validate_no_password_with_encryption(config) +def test_password_with_migration_install_accepted() -> None: + """The old firmware may still ask for the password on the plaintext + leg of the migration install.""" + config = { + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_ALLOW_PLAINTEXT_UPLOAD: True}, + } + assert _validate_no_password_with_encryption(config) is config + + def test_password_alone_accepted() -> None: """A password without encryption still validates.""" config = {CONF_PASSWORD: "pw"} diff --git a/tests/component_tests/ota/test_esphome_ota_migration_password.yaml b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml new file mode 100644 index 00000000000..6a44f75751b --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: ota-migration-password + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/allow_plaintext_upload.yaml b/tests/components/ota/allow_plaintext_upload.yaml new file mode 100644 index 00000000000..be222ec4332 --- /dev/null +++ b/tests/components/ota/allow_plaintext_upload.yaml @@ -0,0 +1,13 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml new file mode 100644 index 00000000000..834e6197acc --- /dev/null +++ b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include allow_plaintext_upload.yaml diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 439220f09c6..60f6b28e076 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -218,6 +218,7 @@ def _upload( firmware: bytes, noise_psk: str | None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: device.start() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -231,13 +232,18 @@ def _upload( Path("firmware.bin"), noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) finally: sock.close() def _run_ota( - device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str + device: FakeEncryptedDevice, + firmware: bytes, + tmp_path: Path, + noise_psk: str, + plaintext_fallback: bool = True, ) -> int: """Drive the retry loop, which is where the plaintext fallback reconnects.""" path = tmp_path / "firmware.bin" @@ -249,7 +255,7 @@ def _run_ota( None, path, noise_psk=noise_psk, - plaintext_fallback=True, + plaintext_fallback=plaintext_fallback, ) return rc @@ -299,11 +305,69 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises(espota2.OTAError, match="refusing to send the image"): + with pytest.raises( + espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" + ): _upload(device, b"firmware", PSK) device.join_and_check() +def test_allow_plaintext_upload_when_device_does_not_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The explicit opt in sends the image in plaintext to a device that + cannot encrypt, naming the option in the warning.""" + firmware = b"firmware" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any("'allow_plaintext_upload' is set" in r.message for r in caplog.records) + assert not any("2027.3.0" in r.message for r in caplog.records) + assert not any( + "Remove it from the configuration" in r.message for r in caplog.records + ) + + +def test_allow_plaintext_upload_warns_once_device_encrypts( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removal warning appears exactly when it is safe to act on: the + device offered encryption and accepted the key with the option still set.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice() + with caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any( + "Remove it from the configuration now" in r.message for r in caplog.records + ) + with caplog.at_level(logging.WARNING): + caplog.clear() + _upload(FakeEncryptedDevice(), firmware, PSK) + assert not caplog.records + + +def test_allow_plaintext_upload_keeps_wrong_key_failing( + caplog: pytest.LogCaptureFixture, +) -> None: + """The opt in only covers a device that does not offer; a rejected key + never turns into a plaintext upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, require_noise=False) + with ( + caplog.at_level(logging.WARNING), + pytest.raises(espota2.OTAError, match="encryption key correct"), + ): + _upload(device, b"firmware", PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received != b"firmware" + assert not any("plaintext" in r.message for r in caplog.records) + + # Remove before 2027.3.0 def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None: """The api key is tried opportunistically; an older device that cannot @@ -519,3 +583,18 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: assert wrapper.recv(1) == b"A" # reads and decrypts one frame assert wrapper.recv(1) == b"B" # served from the buffer, no new frame wrapper._decrypt.decrypt.assert_called_once() + + +def test_bare_block_refuses_a_device_that_cannot_encrypt( + caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """What the CLI sends for a bare `encryption:` block: a key with neither + fallback. The retry loop never reconnects in plaintext.""" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota(device, b"firmware", tmp_path, PSK, plaintext_fallback=False) + device.join_and_check() + assert rc == 1 + assert device.received != b"firmware" + assert any("refusing to send the image" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 8fb9b7376e3..956ba86502c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2115,6 +2115,7 @@ def test_upload_program_ota_success( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2153,9 +2154,75 @@ def test_upload_program_ota_encryption_key( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=False, + allow_plaintext_upload=False, ) +def test_upload_program_bare_encryption_block_never_falls_back( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A bare `ota: encryption:` (the api key inherited by final validate, no + option) fails closed against a device that does not offer encryption.""" + from esphome.components.esphome.ota import ota_esphome_final_validate + import esphome.final_validate as fv + + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: key}}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: {}}], + } + token = fv.full_config.set(config) + try: + ota_esphome_final_validate({}) + config = fv.full_config.get() + finally: + fv.full_config.reset(token) + assert config[CONF_OTA][0][CONF_ENCRYPTION] == {CONF_KEY: key} + + with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: + upload_program(config, MockArgs(), ["192.168.1.100"]) + assert run_ota.call_args.args[5] == key + assert run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_ota_allow_plaintext_upload( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """The uploader side opt in reaches run_ota without the removed fallback.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: key, "allow_plaintext_upload": True}, + } + ] + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[2] == "pw" + assert mock_run_ota.call_args.args[5] == key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": True, + } + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2186,6 +2253,7 @@ def test_upload_program_ota_api_key_opportunistic( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=True, + allow_plaintext_upload=False, ) @@ -2214,7 +2282,10 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == {"plaintext_fallback": False} + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2274,6 +2345,7 @@ def test_upload_program_ota_with_file_arg( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2330,6 +2402,7 @@ def test_upload_program_ota_partition_table_with_file_arg( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2393,6 +2466,7 @@ def test_upload_program_ota_partition_table_mqttip( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2582,6 +2656,7 @@ def test_upload_program_ota_bootloader_with_file_arg( OTA_TYPE_UPDATE_BOOTLOADER, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3077,6 +3152,7 @@ def test_upload_program_ota_with_mqtt_resolution( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3133,6 +3209,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5306,6 +5383,7 @@ def test_upload_program_ota_static_ip_with_mqttip( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5357,6 +5435,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5541,6 +5620,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) From d3fd124c59cae21035bed8067bfad9f8345be18b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:50:32 +0100 Subject: [PATCH 470/759] [core] Lint against std::nothrow, use RAMAllocator instead (#19244) --- AGENTS.md | 4 +++- script/ci-custom.py | 42 +++++++++++++++++++++++++++++++--- tests/script/test_ci_custom.py | 41 ++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 113cfe1b2a9..30ce6dfe9a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -737,7 +737,9 @@ file does, and it is the authority when they disagree. The most useful starting 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. - 7. **Detection:** Look for these patterns in compiler output: + 7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`. + + 8. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) diff --git a/script/ci-custom.py b/script/ci-custom.py index bdf7750ce8f..aaf177c941d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -164,7 +164,21 @@ def lint_post_check(func): return func -def lint_re_check(regex, **kwargs): +def _nolint_in_match(content, haystack, match, mask): + """With masking, only a trailing comment counts: the raw span still holds string contents, and + the masked text is blank exactly where comments and strings were, so NOLINT must sit after the + last real code character of the span.""" + raw = content[match.start() : match.end()] + if not mask: + return "NOLINT" in raw + masked = haystack[match.start() : match.end()].rstrip() + return "NOLINT" in raw[len(masked) :] + + +def lint_re_check(regex, mask=False, prefilter=None, **kwargs): + """mask=True blanks comments and string literals first so prose about the pattern is not reported; + the masked text keeps its length, so match offsets still index the original content. + prefilter is a literal every match must contain, checked before the costlier masking.""" flags = kwargs.pop("flags", re.MULTILINE) prog = re.compile(regex, flags) decor = lint_content_check(**kwargs) @@ -173,8 +187,11 @@ def lint_re_check(regex, **kwargs): @functools.wraps(func) def new_func(fname, content): errs = [] - for match in prog.finditer(content): - if "NOLINT" in match.group(0): + if prefilter is not None and prefilter not in content: + return errs + haystack = _mask_cpp_comments_strings(content) if mask else content + for match in prog.finditer(haystack): + if _nolint_in_match(content, haystack, match, mask): continue lineno = content.count("\n", 0, match.start()) + 1 substr = content[: match.start()] @@ -1199,6 +1216,25 @@ def lint_no_std_bind(fname, match): ) +@lint_re_check( + r"[^\w]std\s*::\s*nothrow\b" + CPP_RE_EOL, + mask=True, + prefilter="nothrow", + include=cpp_include, +) +def lint_no_std_nothrow(fname, match): + return ( + f"{highlight('new (std::nothrow)')} aborts on ESP-IDF when the allocation fails, exceptions are disabled " + f"there, so it never returns nullptr.\n" + f"Please use {highlight('RAMAllocator')} from esphome/core/helpers.h, which does.\n" + f" Before: {highlight('auto *buf = new (std::nothrow) uint8_t[n];')}\n" + f" After: {highlight('auto buf = RAMAllocator().make_unique_array_for_overwrite(n);')}\n" + f"For one object use {highlight('RAMAllocator().make_unique(args...)')}; both return empty on failure.\n" + f"Default flags prefer PSRAM; pass RAMAllocator::PREFER_INTERNAL to keep it where new put it.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") # Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : # inside them are never seen. A char literal is exactly one (escaped) char so a digit separator diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index be2c98051ff..5a943cda083 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -1,4 +1,6 @@ -"""Unit tests for the ESP_LOG-needs-braces lint rule in script/ci-custom.py. +"""Unit tests for the ESP_LOG-needs-braces and std::nothrow lint rules in script/ci-custom.py. + +The nothrow rule is a masked lint_re_check, so its tests also pin the decorator's mask option. The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() call (which becomes an empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These @@ -151,6 +153,43 @@ def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") +# --- std::nothrow --- + + +def _lint_nothrow(content: str) -> list: + return ci_custom.lint_no_std_nothrow("test.cpp", content) + + +def test_nothrow_is_reported_at_its_line_and_column_and_points_at_ramallocator() -> ( + None +): + errors = _lint_nothrow( + "int a;\nint b;\n auto *p = new (std::nothrow) uint8_t[n];\n" + ) + assert [(line, col) for line, col, _msg in errors] == [(3, 18)] + assert "RAMAllocator" in errors[0][2] + + +def test_nothrow_spacing_and_the_nothrow_t_type() -> None: + assert len(_lint_nothrow("auto *p = new (std :: nothrow) Foo;\n")) == 1 + assert not _lint_nothrow( + "void *operator new(size_t n, const std::nothrow_t &) noexcept;\n" + ) + + +def test_nothrow_in_comments_and_strings_is_masked() -> None: + assert not _lint_nothrow("// new (std::nothrow) aborts on ESP-IDF\n") + assert not _lint_nothrow('ESP_LOGD(TAG, "std::nothrow");\n') + + +def test_nothrow_nolint_suppresses() -> None: + assert not _lint_nothrow("auto *p = new (std::nothrow) Foo; // NOLINT\n") + + +def test_nothrow_nolint_inside_a_string_does_not_suppress() -> None: + assert len(_lint_nothrow('auto *p = new (std::nothrow) Foo; log("NOLINT");\n')) == 1 + + # --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- # Real UNIT_ constants that live in each canonical home. From af4a8dac8e574acc554d5bcfb1040d274f8a550a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 15:56:01 +0100 Subject: [PATCH 471/759] [esp32] Keep the NVS cache in PSRAM when PSRAM is guaranteed (#19378) --- esphome/components/esp32/__init__.py | 55 ++++++- .../config/nvs_cache_psram_disabled.yaml | 13 ++ .../config/nvs_cache_psram_encrypted.yaml | 14 ++ .../nvs_cache_psram_encrypted_sdkconfig.yaml | 13 ++ .../config/nvs_cache_psram_explicit.yaml | 13 ++ .../config/nvs_cache_psram_guaranteed.yaml | 11 ++ .../nvs_cache_psram_not_guaranteed.yaml | 10 ++ .../config/nvs_cache_psram_user_off.yaml | 13 ++ tests/component_tests/esp32/test_esp32.py | 135 ++++++++++++++++++ 9 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml create mode 100644 tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0d409b78575..c3da73f5107 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,7 +11,10 @@ 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.components.const import ( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, + CONF_IGNORE_NOT_FOUND, +) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -111,6 +114,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_NVS_CACHE_IN_PSRAM = "nvs_cache_in_psram" CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" @@ -1595,6 +1599,29 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], ) ) + if advanced.get(CONF_NVS_CACHE_IN_PSRAM): + psram_conf = full_config.get(PSRAM_DOMAIN) + if ( + psram_conf is None + or psram_conf[CONF_DISABLED] + or psram_conf[CONF_IGNORE_NOT_FOUND] + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' requires PSRAM with 'ignore_not_found: false'", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) + if ( + advanced.get(CONF_NVS_ENCRYPTION) is not None + or conf_fw[CONF_SDKCONFIG_OPTIONS].get("CONFIG_NVS_ENCRYPTION") == "y" + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' cannot be used with NVS encryption; the keys must stay in internal RAM", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) final_validate_pins(full_config) @@ -2035,6 +2062,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_HEAP_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_NVS_CACHE_IN_PSRAM): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), @@ -2361,6 +2389,20 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _apply_nvs_cache_in_psram(explicit: bool) -> None: + """Keep the NVS cache in PSRAM unless NVS encryption is on, however it was enabled.""" + # The encrypted partition object holds the derived keys, which must stay in internal RAM + if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"): + if explicit: + _LOGGER.warning( + "%s ignored: NVS encryption keeps the NVS cache in internal RAM", + CONF_NVS_CACHE_IN_PSRAM, + ) + return + set_idf_sdkconfig_default("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM", True) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_certificate_bundle_sdkconfig() -> None: """Enable the mbedTLS certificate bundle only when something asked for it. @@ -2913,6 +2955,17 @@ async def to_code(config): if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) + # Imported here as psram imports this module + from esphome.components.psram import is_guaranteed as psram_is_guaranteed + + # Frees internal heap (the cache scales with the NVS partition) but slows NVS, so only + # where PSRAM is known to be fitted. Decided at FINAL so every way of enabling NVS + # encryption has been seen and a user's sdkconfig_options value wins. + # Unset means on; only an explicit true is worth a warning when it has to be dropped. + requested = advanced.get(CONF_NVS_CACHE_IN_PSRAM) + if requested is not False and psram_is_guaranteed(): + CORE.add_job(_apply_nvs_cache_in_psram, requested is True) + # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides # significant performance benefits. Our benchmarks show socket operations are diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml new file mode 100644 index 00000000000..bcfc1eaf4c8 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: false + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml new file mode 100644 index 00000000000..d73b5045da0 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml new file mode 100644 index 00000000000..3e1b32ba410 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ENCRYPTION: "y" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml new file mode 100644 index 00000000000..86174146d67 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: true + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml new file mode 100644 index 00000000000..618d265cc0a --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml new file mode 100644 index 00000000000..b52255c5adb --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml new file mode 100644 index 00000000000..ab4043ccacc --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM: "n" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 08f5dcc8227..b41234db1a0 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -4,6 +4,7 @@ Test ESP32 configuration import asyncio from collections.abc import Callable +import logging from pathlib import Path from typing import Any @@ -1575,3 +1576,137 @@ def test_vasprintf_stub_only_on_rom_vsnprintf_variants( assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected defines = {define.name for define in CORE.defines} assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("nvs_cache_psram_guaranteed.yaml", True), + ("nvs_cache_psram_explicit.yaml", True), + ("nvs_cache_psram_not_guaranteed.yaml", None), + ("nvs_cache_psram_disabled.yaml", None), + # the encryption keys must stay in internal RAM, whichever way encryption is enabled + ("nvs_cache_psram_encrypted.yaml", None), + ("nvs_cache_psram_encrypted_sdkconfig.yaml", None), + ], +) +def test_nvs_cache_in_psram_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + expected: bool | None, +) -> None: + """The NVS cache moves to PSRAM only with guaranteed PSRAM, the option not off and no NVS encryption.""" + generate_main(component_config_path(fixture)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is expected + + +def test_nvs_cache_in_psram_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A raw sdkconfig_options value for the NVS cache option is left alone.""" + generate_main(component_config_path("nvs_cache_psram_user_off.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM"] == RawSdkconfigValue("n") + + +@pytest.mark.parametrize( + ("full_config", "error_match"), + [ + pytest.param( + {CONF_ESPHOME: {}, "psram": {"disabled": False, "ignore_not_found": True}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_guaranteed_psram", + ), + pytest.param( + {CONF_ESPHOME: {}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_psram", + ), + ], +) +def test_nvs_cache_in_psram_explicit_true_errors( + full_config: dict, error_match: str, set_core_config: SetCoreConfigCallable +) -> None: + """An explicit nvs_cache_in_psram: true that cannot apply is a config error, not a silent no-op.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": {"type": "esp-idf", "advanced": {"nvs_cache_in_psram": True}}, + } + with pytest.raises(cv.Invalid, match=error_match): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_explicit_true_rejects_encryption( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + CONF_ESPHOME: {}, + "psram": {"disabled": False, "ignore_not_found": False}, + }, + ) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_cache_in_psram": True, "nvs_encryption": {"key_id": 0}}, + }, + } + with pytest.raises(cv.Invalid, match="cannot be used with NVS encryption"): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_default_with_encryption_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Encryption on a board that never mentioned the option must not warn about it.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_encrypted.yaml")) + assert "nvs_cache_in_psram" not in caplog.text + + +@pytest.mark.asyncio +async def test_nvs_cache_in_psram_explicit_request_warns_when_encrypted( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """An explicit request dropped for NVS encryption enabled elsewhere logs a warning.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_SDKCONFIG_OPTIONS: {}} + ) + from esphome.components.esp32 import ( + _apply_nvs_cache_in_psram, + add_idf_sdkconfig_option, + ) + + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + with caplog.at_level(logging.WARNING): + await _apply_nvs_cache_in_psram(True) + assert "nvs_cache_in_psram ignored" in caplog.text + assert ( + "CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM" + not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + ) + + +def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """An explicit true that applies sets the option and warns about nothing.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_explicit.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True + assert "nvs_cache_in_psram" not in caplog.text From 45bcff2c2c8ea389169003ebf14ebc97603953c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:03:32 +0100 Subject: [PATCH 472/759] [micro_wake_word] Use the apply helpers for the start, stop and model automations (#19620) --- .../components/micro_wake_word/__init__.py | 50 +++++------------ .../components/micro_wake_word/automation.h | 54 ------------------- 2 files changed, 14 insertions(+), 90 deletions(-) delete mode 100644 esphome/components/micro_wake_word/automation.h diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 092c4977ce1..f04f784ebcf 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,7 +6,6 @@ import re from urllib.parse import urljoin from esphome import automation, external_files, git -from esphome.automation import register_action, register_condition from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram @@ -58,17 +57,6 @@ micro_wake_word_ns = cg.esphome_ns.namespace("micro_wake_word") MicroWakeWord = micro_wake_word_ns.class_("MicroWakeWord", cg.Component) -DisableModelAction = micro_wake_word_ns.class_("DisableModelAction", automation.Action) -EnableModelAction = micro_wake_word_ns.class_("EnableModelAction", automation.Action) -StartAction = micro_wake_word_ns.class_("StartAction", automation.Action) -StopAction = micro_wake_word_ns.class_("StopAction", automation.Action) - -ModelIsEnabledCondition = micro_wake_word_ns.class_( - "ModelIsEnabledCondition", automation.Condition -) -IsRunningCondition = micro_wake_word_ns.class_( - "IsRunningCondition", automation.Condition -) WakeWordModel = micro_wake_word_ns.class_("WakeWordModel") @@ -619,23 +607,19 @@ async def to_code(config): MICRO_WAKE_WORD_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(MicroWakeWord)}) - -@register_action( +automation.register_apply_action( "micro_wake_word.start", - StartAction, MICRO_WAKE_WORD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start()"), ) -@register_action( - "micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "micro_wake_word.stop", + MICRO_WAKE_WORD_ACTION_SCHEMA, + automation.ApplyCall("stop()"), ) -@register_condition( - "micro_wake_word.is_running", IsRunningCondition, MICRO_WAKE_WORD_ACTION_SCHEMA +automation.register_apply_condition( + "micro_wake_word.is_running", MICRO_WAKE_WORD_ACTION_SCHEMA, "is_running()" ) -async def micro_wake_word_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( @@ -644,24 +628,18 @@ MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@register_action( +automation.register_apply_action( "micro_wake_word.enable_model", - EnableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("enable()"), ) -@register_action( +automation.register_apply_action( "micro_wake_word.disable_model", - DisableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("disable()"), ) -@register_condition( +automation.register_apply_condition( "micro_wake_word.model_is_enabled", - ModelIsEnabledCondition, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + "is_enabled()", ) -async def model_action(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h deleted file mode 100644 index 59dfc624fad..00000000000 --- a/esphome/components/micro_wake_word/automation.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include "micro_wake_word.h" -#include "streaming_model.h" - -#ifdef USE_ESP32 - -namespace esphome::micro_wake_word { - -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class EnableModelAction final : public Action { - public: - explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->enable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class DisableModelAction final : public Action { - public: - explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->disable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class ModelIsEnabledCondition final : public Condition { - public: - explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -} // namespace esphome::micro_wake_word - -#endif From db566b25d0bea7df28523847d3a69a5bd9d7a381 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:04:14 +0100 Subject: [PATCH 473/759] [microphone] Use the apply helpers for the capture, mute and condition automations (#19619) --- esphome/components/microphone/__init__.py | 75 +++++-------------- esphome/components/microphone/automation.h | 25 ------- .../components/microphone/common-actions.yaml | 12 +++ .../microphone/test-actions.esp32-idf.yaml | 8 ++ 4 files changed, 37 insertions(+), 83 deletions(-) create mode 100644 tests/components/microphone/common-actions.yaml create mode 100644 tests/components/microphone/test-actions.esp32-idf.yaml diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 9a3f5b43e7b..c604f64d775 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -14,9 +14,9 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -29,30 +29,11 @@ microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") MicrophoneSource = microphone_ns.class_("MicrophoneSource") -CaptureAction = microphone_ns.class_( - "CaptureAction", automation.Action, cg.Parented.template(Microphone) -) -StopCaptureAction = microphone_ns.class_( - "StopCaptureAction", automation.Action, cg.Parented.template(Microphone) -) -MuteAction = microphone_ns.class_( - "MuteAction", automation.Action, cg.Parented.template(Microphone) -) -UnmuteAction = microphone_ns.class_( - "UnmuteAction", automation.Action, cg.Parented.template(Microphone) -) - - DataTrigger = microphone_ns.class_( "DataTrigger", automation.Trigger.template(cg.std_vector.template(cg.uint8).operator("ref")), ) -IsCapturingCondition = microphone_ns.class_( - "IsCapturingCondition", automation.Condition -) -IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) - async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): @@ -189,44 +170,22 @@ async def microphone_source_to_code( return mic_source -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 +for _name, _call in ( + ("microphone.capture", "start()"), + ("microphone.stop_capture", "stop()"), + ("microphone.mute", "set_mute_state(true)"), + ("microphone.unmute", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, MICROPHONE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -automation.register_action( - "microphone.capture", - CaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.stop_capture", - StopCaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) -automation.register_action( - "microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) - -automation.register_condition( - "microphone.is_capturing", IsCapturingCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) -automation.register_condition( - "microphone.is_muted", IsMutedCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) +automation.register_apply_condition( + "microphone.is_capturing", MICROPHONE_ACTION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "microphone.is_muted", MICROPHONE_ACTION_SCHEMA, "get_mute_state()" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index c28616a290a..0fac18d8283 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,21 +7,6 @@ namespace esphome::microphone { -template class CaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopCaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class MuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(true); } -}; -template class UnmuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(false); } -}; - class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { @@ -29,14 +14,4 @@ class DataTrigger final : public Trigger &> { } }; -template class IsCapturingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } -}; - } // namespace esphome::microphone diff --git a/tests/components/microphone/common-actions.yaml b/tests/components/microphone/common-actions.yaml new file mode 100644 index 00000000000..885282bcdc7 --- /dev/null +++ b/tests/components/microphone/common-actions.yaml @@ -0,0 +1,12 @@ +# capture, stop_capture and is_capturing on the external microphone. +button: + - platform: template + name: Microphone Capture + on_press: + - if: + condition: + microphone.is_capturing: mic_id_external + then: + - microphone.stop_capture: mic_id_external + else: + - microphone.capture: mic_id_external diff --git a/tests/components/microphone/test-actions.esp32-idf.yaml b/tests/components/microphone/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..c2f27a42861 --- /dev/null +++ b/tests/components/microphone/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + microphone: !include common-pdm.yaml + actions: !include common-actions.yaml From d346835e79732c008484b90c75e4b73512f103f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:04:51 +0100 Subject: [PATCH 474/759] [wireguard] Use the apply helpers for the enable, disable, enabled and peer_online automations (#19616) --- esphome/components/wireguard/__init__.py | 60 ++++-------------------- esphome/components/wireguard/wireguard.h | 26 ---------- 2 files changed, 10 insertions(+), 76 deletions(-) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ea9e5a3b0c9..ac62812fcf9 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -41,16 +41,6 @@ _WG_KEY_REGEX = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") wireguard_ns = cg.esphome_ns.namespace("wireguard") Wireguard = wireguard_ns.class_("Wireguard", cg.Component, cg.PollingComponent) AllowedIP = wireguard_ns.struct("AllowedIP") -WireguardPeerOnlineCondition = wireguard_ns.class_( - "WireguardPeerOnlineCondition", automation.Condition -) -WireguardEnabledCondition = wireguard_ns.class_( - "WireguardEnabledCondition", automation.Condition -) -WireguardEnableAction = wireguard_ns.class_("WireguardEnableAction", automation.Action) -WireguardDisableAction = wireguard_ns.class_( - "WireguardDisableAction", automation.Action -) def _wireguard_key(value): @@ -163,47 +153,17 @@ async def to_code(config): await cg.register_component(var, config) -@automation.register_condition( - "wireguard.peer_online", - WireguardPeerOnlineCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +WIREGUARD_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}) + +automation.register_apply_condition( + "wireguard.peer_online", WIREGUARD_AUTOMATION_SCHEMA, "is_peer_up()" ) -async def wireguard_peer_up_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_condition( - "wireguard.enabled", - WireguardEnabledCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +automation.register_apply_condition( + "wireguard.enabled", WIREGUARD_AUTOMATION_SCHEMA, "is_enabled()" ) -async def wireguard_enabled_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.enable", - WireguardEnableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.enable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("enable()") ) -async def wireguard_enable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.disable", - WireguardDisableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.disable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("disable()") ) -async def wireguard_disable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c9c2feb7aef..68885f3a351 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -4,7 +4,6 @@ #include #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/time/real_time_clock.h" @@ -164,30 +163,5 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); -/// Condition to check if remote peer is online. -template -class WireguardPeerOnlineCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } -}; - -/// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_enabled(); } -}; - -/// Action to enable Wireguard component. -template class WireguardEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enable(); } -}; - -/// Action to disable Wireguard component. -template class WireguardDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->disable(); } -}; - } // namespace esphome::wireguard #endif From 044c4cb9bb9ce5622650cdadd89cb8b06dde0f05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:46:12 +0100 Subject: [PATCH 475/759] [core] Migrate trivial action and condition builders to the shared helpers (#19322) --- .../alarm_control_panel/__init__.py | 31 ++----- esphome/components/binary_sensor/__init__.py | 5 +- esphome/components/bl0906/sensor.py | 13 +-- esphome/components/ble_client/__init__.py | 15 +--- esphome/components/bm8563/time.py | 22 +---- esphome/components/cc1101/__init__.py | 38 ++++++--- esphome/components/cm1106/sensor.py | 13 +-- esphome/components/cs5460a/sensor.py | 12 +-- esphome/components/dfplayer/__init__.py | 6 +- .../components/dfrobot_sen0395/__init__.py | 12 +-- .../components/display_menu_base/__init__.py | 80 +++++++++---------- esphome/components/ds1307/time.py | 24 +----- esphome/components/duty_time/sensor.py | 57 ++++--------- esphome/components/esp32_ble/__init__.py | 48 +++++------ esphome/components/ethernet/__init__.py | 35 +++----- esphome/components/ezo_pmp/__init__.py | 32 ++------ .../components/fingerprint_grow/__init__.py | 12 +-- .../components/grove_tb6612fng/__init__.py | 14 +--- esphome/components/haier/climate.py | 49 ++++++------ esphome/components/hbridge/fan/__init__.py | 13 +-- esphome/components/hdc302x/sensor.py | 11 +-- esphome/components/integration/sensor.py | 11 +-- esphome/components/key_collector/__init__.py | 23 +----- esphome/components/max17043/sensor.py | 17 ++-- esphome/components/mcp4461/output/__init__.py | 36 ++++----- esphome/components/mhz19/sensor.py | 19 ++--- .../components/mitsubishi_cn105/__init__.py | 11 +-- esphome/components/nau7802/sensor.py | 21 ++--- esphome/components/pcf85063/time.py | 24 +----- esphome/components/pcf8563/time.py | 24 +----- esphome/components/pmwcs3/sensor.py | 14 +--- esphome/components/pn532/__init__.py | 15 +--- esphome/components/pn7150/__init__.py | 65 ++++++++------- esphome/components/pn7160/__init__.py | 65 ++++++++------- esphome/components/pzemac/sensor.py | 12 +-- esphome/components/pzemdc/sensor.py | 12 +-- esphome/components/rx8130/time.py | 24 +----- esphome/components/scd4x/sensor.py | 11 +-- esphome/components/sen5x/sensor.py | 12 +-- esphome/components/senseair/sensor.py | 28 +++---- esphome/components/sim800l/__init__.py | 10 +-- esphome/components/sound_level/sensor.py | 27 +++---- esphome/components/sprinkler/__init__.py | 49 +++++++----- esphome/components/sps30/sensor.py | 21 ++--- esphome/components/sx126x/__init__.py | 23 +++--- esphome/components/sx127x/__init__.py | 27 +++---- esphome/components/tinyusb/__init__.py | 12 +-- esphome/components/tm1651/__init__.py | 27 ++----- esphome/components/ufire_ec/sensor.py | 10 +-- esphome/components/ufire_ise/sensor.py | 10 +-- esphome/components/update/__init__.py | 26 +----- esphome/components/valve/__init__.py | 40 +++++----- .../components/voice_assistant/__init__.py | 33 ++------ esphome/components/wifi/__init__.py | 71 ++++++++-------- esphome/components/zigbee/__init__.py | 13 +-- 55 files changed, 455 insertions(+), 930 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 001ecd88616..b0fea438017 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -208,65 +208,50 @@ for _name, _arm in ( ) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_pending_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_trigger_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -async def alarm_action_chime_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA, synchronous=True, ) -@automation.register_condition( + + +automation.register_simple_condition( "alarm_control_panel.ready", AlarmControlPanelCondition, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, ) -async def alarm_action_ready_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_simple_condition( "alarm_control_panel.is_armed", AlarmControlPanelCondition, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, ) -async def alarm_control_panel_is_armed_to_code( - config, condition_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index da0276c037d..696d7345ddc 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -655,7 +655,7 @@ async def to_code(config): cg.add_global(binary_sensor_ns.using) -@automation.register_action( +automation.register_simple_action( "binary_sensor.invalidate_state", BinarySensorInvalidateAction, cv.maybe_simple_value( @@ -666,9 +666,6 @@ async def to_code(config): ), synchronous=True, ) -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 diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 1a0c2287ab7..e24cd316f4e 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,8 +32,6 @@ 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 @@ -138,7 +136,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -@automation.register_action( +automation.register_parented_action( "bl0906.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -148,15 +146,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 1ef7967fa83..810f4b66e3d 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -207,26 +207,20 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "ble_client.disconnect", BLEDisconnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_disconnect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) -@automation.register_action( +automation.register_simple_action( "ble_client.connect", BLEConnectAction, BLE_CONNECT_ACTION_SCHEMA, synchronous=False, ) -async def ble_connect_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) @automation.register_action( @@ -328,15 +322,12 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, synchronous=True, ) -async def remove_bond_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 5ef162bb7c4..3a15d0eb1c0 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_parented_action( "bm8563.write_time", WriteAction, automation.maybe_simple_id( @@ -38,15 +38,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -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 @automation.register_action( @@ -73,7 +64,7 @@ async def bm8563_start_timer_to_code( return var -@automation.register_action( +automation.register_parented_action( "bm8563.read_time", ReadAction, automation.maybe_simple_id( @@ -83,15 +74,6 @@ async def bm8563_start_timer_to_code( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index dbafae4ad0f..d851d7386df 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -364,22 +364,36 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "cc1101.begin_tx", + BeginTxAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.begin_rx", + BeginRxAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.reset", + ResetAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "cc1101.set_idle", + SetIdleAction, + CC1101_ACTION_SCHEMA, + synchronous=True, ) -async def cc1101_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var def validate_raw_data(value): diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index a36f0b00594..cbb0509ce50 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,8 +13,6 @@ 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"] @@ -72,18 +70,9 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "cm1106.calibrate_zero", CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -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/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 5f14457101f..946037fc34d 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,8 +17,6 @@ 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"] @@ -131,7 +129,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_power_sensor(sens)) -@automation.register_action( +automation.register_simple_action( "cs5460a.restart", CS5460ARestartAction, maybe_simple_id( @@ -141,11 +139,3 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -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/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d2120deaa61..0516b27ca21 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -199,7 +199,7 @@ automation.register_apply_action( ) -@automation.register_condition( +automation.register_parented_condition( "dfplayer.is_playing", DFPlayerIsPlayingCondition, cv.Schema( @@ -208,7 +208,3 @@ automation.register_apply_action( } ), ) -async def dfplayer_is_playing_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 51562f923cb..81d57026cc9 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -49,7 +49,7 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) -@automation.register_action( +automation.register_parented_action( "dfrobot_sen0395.reset", DfrobotSen0395ResetAction, maybe_simple_id( @@ -59,16 +59,6 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -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: Any) -> list: diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 2120abe5f79..a5204cfe2fc 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -294,74 +294,71 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "display_menu.up", UpAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.up", + UpAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_up_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.down", DownAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.down", + DownAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_down_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.left", LeftAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.left", + LeftAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_left_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.right", RightAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.right", + RightAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_right_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.enter", EnterAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.enter", + EnterAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_enter_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.show", ShowAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.show", + ShowAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_show_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "display_menu.hide", HideAction, MENU_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "display_menu.hide", + HideAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) -async def menu_hide_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "display_menu.show_main", ShowMainAction, MENU_ACTION_SCHEMA, synchronous=True, ) -async def menu_show_main_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_simple_condition( "display_menu.is_active", IsActiveCondition, automation.maybe_simple_id( @@ -370,9 +367,6 @@ async def menu_show_main_to_code(config, action_id, template_arg, args): } ), ) -async def display_menu_is_active_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) async def menu_item_to_code(menu, config, parent): diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index a3ae3eb5af8..278494c81fc 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,8 +3,6 @@ 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"] @@ -22,7 +20,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x68)) -@automation.register_action( +automation.register_parented_action( "ds1307.write_time", WriteAction, cv.Schema( @@ -32,18 +30,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -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 -@automation.register_action( +automation.register_parented_action( "ds1307.read_time", ReadAction, automation.maybe_simple_id( @@ -53,15 +42,6 @@ async def ds1307_write_time_to_code( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6d878a80a5e..af7e14abb81 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -1,10 +1,5 @@ -from esphome.automation import ( - Action, - Condition, - maybe_simple_id, - register_action, - register_condition, -) +from esphome import automation +from esphome.automation import Action, Condition, maybe_simple_id, register_condition import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv @@ -93,46 +88,28 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( ) -@register_action( - "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.start", + StartAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -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 -@register_action( - "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.stop", + StopAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -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 -@register_action( - "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True +automation.register_parented_action( + "sensor.duty_time.reset", + ResetAction, + DUTY_TIME_ID_SCHEMA, + synchronous=True, ) -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 @register_condition( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 7e97111686b..77ec17f3891 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -32,8 +32,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, ID, TimePeriod -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType @@ -606,38 +605,27 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -@automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -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 +automation.register_bare_condition( + "ble.enabled", + BLEEnabledCondition, + cv.Schema({}), ) -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 +automation.register_bare_action( + "ble.enable", + BLEEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "ble.disable", + BLEDisableAction, + cv.Schema({}), + synchronous=True, ) -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) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3e7d345805c..920f2e5e4bf 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -53,12 +53,10 @@ 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 @@ -924,24 +922,15 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -async def _new_pvariable_to_code( - config: ConfigType, - id_: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - return cg.new_Pvariable(id_, template_arg) - - -for _name, _cls in ( - ("ethernet.connected", EthernetConnectedCondition), - ("ethernet.enabled", EthernetEnabledCondition), -): - automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) -for _name, _cls in ( - ("ethernet.enable", EthernetEnableAction), - ("ethernet.disable", EthernetDisableAction), -): - automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( - _new_pvariable_to_code - ) +automation.register_bare_condition( + "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) +) +automation.register_bare_condition( + "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) +) +automation.register_bare_action( + "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True +) +automation.register_bare_action( + "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +) diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 0793495e1ac..3477de88a4e 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -80,72 +80,52 @@ EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( ) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.find", EzoPMPFindAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_find_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.dose_continuously", EzoPMPDoseContinuouslyAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.clear_total_volume_dosed", EzoPMPClearTotalVolumeDispensedAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_clear_total_volume_dosed_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.clear_calibration", EzoPMPClearCalibrationAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.pause_dosing", EzoPMPPauseDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "ezo_pmp.stop_dosing", EzoPMPStopDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def ezo_pmp_stop_dosing_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) # Actions that require Multiple Args diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 8d935a3c9ed..85fe3994c52 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -206,7 +206,7 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) return var -@automation.register_action( +automation.register_parented_action( "fingerprint_grow.cancel_enroll", CancelEnrollmentAction, cv.Schema( @@ -216,10 +216,6 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var @automation.register_action( @@ -243,7 +239,7 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) return var -@automation.register_action( +automation.register_parented_action( "fingerprint_grow.delete_all", DeleteAllAction, cv.Schema( @@ -253,10 +249,6 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def fingerprint_grow_delete_all_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index ae64c049f53..e7937b1f298 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -126,7 +126,7 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_parented_action( "grove_tb6612fng.standby", GROVETB6612FNGMotorStandbyAction, cv.Schema( @@ -136,14 +136,9 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - return var -@automation.register_action( +automation.register_parented_action( "grove_tb6612fng.no_standby", GROVETB6612FNGMotorNoStandbyAction, cv.Schema( @@ -153,11 +148,6 @@ async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args) ), synchronous=True, ) -async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - return var @automation.register_action( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 70ae36f5281..5a87ec1557a 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -284,56 +284,53 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "climate.haier.display_on", DisplayOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.display_off", DisplayOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def display_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "climate.haier.beeper_on", BeeperOnAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.beeper_off", BeeperOffAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -async def beeper_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) # Start self cleaning or steri-cleaning action action -@automation.register_action( +automation.register_simple_action( "climate.haier.start_self_cleaning", StartSelfCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.start_steri_cleaning", StartSteriCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, synchronous=True, ) -async def start_cleaning_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) # Set vertical airflow direction action @@ -384,44 +381,44 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, return var -@automation.register_action( +automation.register_simple_action( "climate.haier.health_on", HealthOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.health_off", HealthOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def health_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "climate.haier.power_on", PowerOnAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.power_off", PowerOffAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "climate.haier.power_toggle", PowerToggleAction, HAIER_BASE_ACTION_SCHEMA, synchronous=True, ) -async def power_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config) -> None: diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 2cf1693b472..cd47d100be4 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -7,14 +7,11 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DECAY_MODE, CONF_ENABLE_PIN, - CONF_ID, CONF_PIN_A, CONF_PIN_B, 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 @@ -51,20 +48,12 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "fan.hbridge.brake", BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 6d91c3df7c2..31671c5b1e5 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -139,18 +139,9 @@ async def hdc302x_heater_on_to_code( return var -@automation.register_action( +automation.register_parented_action( "hdc302x.heater_off", HeaterOffAction, HDC302X_ACTION_SCHEMA, synchronous=True, ) -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/integration/sensor.py b/esphome/components/integration/sensor.py index 82e8ba8df8e..672af46e3e3 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -106,7 +106,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -@automation.register_action( +automation.register_parented_action( "sensor.integration.reset", ResetAction, automation.maybe_simple_id( @@ -116,15 +116,6 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -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 @automation.register_action( diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index bf47b6df888..c95bdc46b00 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,7 +15,6 @@ 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 ConfigType, TemplateArgsType @@ -135,7 +134,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_enabled(config[CONF_ENABLE_ON_BOOT])) -@automation.register_action( +automation.register_parented_action( "key_collector.enable", EnableAction, automation.maybe_simple_id( @@ -145,18 +144,9 @@ async def to_code(config: ConfigType) -> None: ), synchronous=True, ) -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 -@automation.register_action( +automation.register_parented_action( "key_collector.disable", DisableAction, automation.maybe_simple_id( @@ -166,12 +156,3 @@ async def enable_to_code( ), synchronous=True, ) -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/max17043/sensor.py b/esphome/components/max17043/sensor.py index 67fb8aa5b7b..7de1424a96d 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,8 +14,6 @@ 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"] @@ -74,14 +72,9 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "max17043.sleep_mode", + SleepAction, + MAX17043_ACTION_SCHEMA, + synchronous=True, ) -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/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index db1a1e6a29f..d2b4b321742 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -144,36 +144,28 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "mcp4461.wiper.increase", + WiperIncreaseAction, + WIPER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True -) -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) -@automation.register_action( +automation.register_simple_action( + "mcp4461.wiper.decrease", + WiperDecreaseAction, + WIPER_ACTION_SCHEMA, + synchronous=True, +) + + +automation.register_simple_action( "mcp4461.wiper.store_nonvolatile", WiperStoreNonvolatileAction, WIPER_ACTION_SCHEMA, synchronous=True, ) -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) @automation.register_action( diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 5852686608c..4434eddb08d 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -122,33 +122,28 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "mhz19.calibrate_zero", MHZ19CalibrateZeroAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "mhz19.abc_enable", MHZ19ABCEnableAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "mhz19.abc_disable", MHZ19ABCDisableAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -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 RANGE_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 9502773616e..8f1c6ef648b 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -162,21 +162,12 @@ async def remote_temperature_action_to_code( return var -@automation.register_action( +automation.register_parented_action( f"{DOMAIN}.clear_remote_temperature", ClearRemoteTemperatureAction, CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_temperature_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 VANE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 415ae09dafa..3112df99d03 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -4,8 +4,6 @@ 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"] @@ -116,30 +114,25 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "nau7802.calibrate_internal_offset", NAU7802CalbrateInternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "nau7802.calibrate_external_offset", NAU7802CalbrateExternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "nau7802.calibrate_gain", NAU7802CalbrateGainAction, NAU7802_CALIBRATE_SCHEMA, synchronous=True, ) -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/pcf85063/time.py b/esphome/components/pcf85063/time.py index 771461905e0..cc7a373b42d 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -3,8 +3,6 @@ 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"] @@ -24,7 +22,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( +automation.register_parented_action( "pcf85063.write_time", WriteAction, cv.Schema( @@ -34,18 +32,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -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 -@automation.register_action( +automation.register_parented_action( "pcf85063.read_time", ReadAction, automation.maybe_simple_id( @@ -55,15 +44,6 @@ async def pcf85063_write_time_to_code( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 8a0b871be9b..141949bf35d 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -3,8 +3,6 @@ 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"] @@ -27,7 +25,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -@automation.register_action( +automation.register_parented_action( "pcf8563.write_time", WriteAction, automation.maybe_simple_id( @@ -37,18 +35,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -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 -@automation.register_action( +automation.register_parented_action( "pcf8563.read_time", ReadAction, automation.maybe_simple_id( @@ -58,15 +47,6 @@ async def pcf8563_write_time_to_code( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index ae22b3e0d6c..552cfafa04d 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -105,26 +105,20 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "pmwcs3.air_calibration", PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "pmwcs3.water_calibration", PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, synchronous=True, ) -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) PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 62589323121..d396050511f 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -3,14 +3,12 @@ import esphome.codegen as cg from esphome.components import nfc import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_ON_FINISHED_WRITE, CONF_ON_TAG, CONF_ON_TAG_REMOVED, CONF_TRIGGER_ID, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@jesserockz"] @@ -79,7 +77,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn532.is_writing", PN532IsWritingCondition, cv.Schema( @@ -88,12 +86,3 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: } ), ) -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/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 4638992abf3..da5f4824646 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -127,54 +127,68 @@ async def pn7150_set_message_to_code( return var -@automation.register_action( +automation.register_parented_action( "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.emulation_on", + EmulationOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_off", + PollingOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_on", + PollingOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -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 _CALLBACK_AUTOMATIONS = ( @@ -220,7 +234,7 @@ async def setup_pn7150(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn7150.is_writing", PN7150IsWritingCondition, cv.Schema( @@ -229,12 +243,3 @@ async def setup_pn7150(var: MockObj, config: ConfigType) -> None: } ), ) -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 7f9f9172a15..5ce0dd12861 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -131,54 +131,68 @@ async def pn7160_set_message_to_code( return var -@automation.register_action( +automation.register_parented_action( "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.emulation_on", + EmulationOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_off", + PollingOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "tag.polling_on", + PollingOnAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA, synchronous=True, ) -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 _CALLBACK_AUTOMATIONS = ( @@ -232,7 +246,7 @@ async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_condition( +automation.register_parented_condition( "pn7160.is_writing", PN7160IsWritingCondition, cv.Schema( @@ -241,12 +255,3 @@ async def setup_pn7160(var: MockObj, config: ConfigType) -> None: } ), ) -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 diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index f093262e18b..1670204468c 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,8 +26,6 @@ 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"] @@ -85,7 +83,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "pzemac.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -95,14 +93,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -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) def _final_validate(config: ConfigType) -> None: diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b9f7246b721..97e9d09d058 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,8 +20,6 @@ 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"] @@ -67,7 +65,7 @@ CONFIG_SCHEMA = ( ) -@automation.register_action( +automation.register_simple_action( "pzemdc.reset_energy", ResetEnergyAction, maybe_simple_id( @@ -77,14 +75,6 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -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) def _final_validate(config: ConfigType) -> None: diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 40d10e9f6b4..0e67593fb32 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -3,8 +3,6 @@ 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"] @@ -22,7 +20,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x32)) -@automation.register_action( +automation.register_parented_action( "rx8130.write_time", WriteAction, cv.Schema( @@ -32,18 +30,9 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -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 -@automation.register_action( +automation.register_parented_action( "rx8130.read_time", ReadAction, automation.maybe_simple_id( @@ -53,15 +42,6 @@ async def rx8130_write_time_to_code( ), synchronous=True, ) -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: ConfigType) -> None: diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index af3ff3a7af7..fcae9e670ed 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -166,18 +166,9 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "scd4x.factory_reset", FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA, synchronous=True, ) -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 e86c8bf899e..e7f61d8b2de 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -43,8 +43,6 @@ 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"] @@ -282,17 +280,9 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "sen5x.start_fan_autoclean", StartFanAction, SEN5X_ACTION_SCHEMA, synchronous=True, ) -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/senseair/sensor.py b/esphome/components/senseair/sensor.py index 82368a60d02..ac92efa3a40 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -11,8 +11,6 @@ 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"] @@ -82,41 +80,41 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_simple_action( "senseair.background_calibration", SenseAirBackgroundCalibrationAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.background_calibration_result", SenseAirBackgroundCalibrationResultAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_enable", SenseAirABCEnableAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_disable", SenseAirABCDisableAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "senseair.abc_get_period", SenseAirABCGetPeriodAction, CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -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/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index ae7ee6fa59f..7f42edb8631 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -125,15 +125,12 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "sim800l.connect", Sim800LConnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), synchronous=True, ) -async def sim800l_connect_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) SIM800L_SEND_USSD_SCHEMA = cv.Schema( @@ -158,12 +155,9 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "sim800l.disconnect", Sim800LDisconnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), synchronous=True, ) -async def sim800l_disconnect_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) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index d2175340415..fe00ae41ba7 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -11,8 +11,6 @@ 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"] @@ -92,18 +90,17 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "sound_level.start", + StartAction, + SOUND_LEVEL_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True + + +automation.register_parented_action( + "sound_level.stop", + StopAction, + SOUND_LEVEL_ACTION_SCHEMA, + synchronous=True, ) -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/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 21863e26217..f14ea0cdeb9 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -446,26 +446,20 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_simple_action( "sprinkler.start_from_queue", StartFromQueueAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( +automation.register_simple_action( "sprinkler.start_full_cycle", StartFullCycleAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( @@ -485,45 +479,60 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, return var -@automation.register_action( +automation.register_simple_action( "sprinkler.clear_queued_valves", ClearQueuedValvesAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.next_valve", NextValveAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.previous_valve", PreviousValveAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA, synchronous=True + + +automation.register_simple_action( + "sprinkler.pause", + PauseAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( - "sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA, synchronous=True + + +automation.register_simple_action( + "sprinkler.resume", + ResumeAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_simple_action( "sprinkler.shutdown", ShutdownAction, SPRINKLER_ACTION_SCHEMA, synchronous=True, ) -async def sprinkler_simple_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) async def to_code(config): diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 681166cd3c6..a310993d82c 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,8 +26,6 @@ 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"] @@ -182,30 +180,25 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA, synchronous=True, ) -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/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 29e3ad5359f..f8f51296a16 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -302,39 +302,36 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sx126x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx126x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -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 SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 34f2d4122f6..5701f2acb7c 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -286,45 +286,44 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( +automation.register_parented_action( "sx127x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( + + +automation.register_parented_action( "sx127x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -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 SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 7ad88d3018d..422a9942380 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -12,8 +12,6 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -136,16 +134,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -@automation.register_condition( +automation.register_simple_condition( "tinyusb.is_mounted", IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), ) -async def tinyusb_is_mounted_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index c0cc6f1d2cb..b07576e443a 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -141,32 +141,17 @@ async def tm1651_set_level_percent_to_code( return var -@automation.register_action( +automation.register_parented_action( "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True, ) -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 -@automation.register_action( - "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True +automation.register_parented_action( + "tm1651.turn_on", + TurnOnAction, + BINARY_OUTPUT_ACTION_SCHEMA, + synchronous=True, ) -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 9d989ad4e67..406de3eff35 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -124,17 +124,9 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_simple_action( "ufire_ec.reset", UFireECResetAction, UFIRE_EC_RESET_SCHEMA, synchronous=True, ) -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 c7e3b6f28d1..aab30ff50e4 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -131,17 +131,9 @@ async def ufire_ise_calibrate_probe_high_to_code( UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent)}) -@automation.register_action( +automation.register_simple_action( "ufire_ise.reset", UFireISEResetAction, UFIRE_ISE_RESET_SCHEMA, synchronous=True, ) -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) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 818358c48a1..87707159893 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,14 +14,14 @@ from esphome.const import ( DEVICE_CLASS_FIRMWARE, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -144,7 +144,7 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_parented_action( "update.check", CheckAction, automation.maybe_simple_id( @@ -154,18 +154,9 @@ automation.register_apply_action( ), synchronous=True, ) -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 -@automation.register_condition( +automation.register_parented_condition( "update.is_available", IsAvailableCondition, automation.maybe_simple_id( @@ -174,12 +165,3 @@ async def update_check_action_to_code( } ), ) -async def update_is_available_condition_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/valve/__init__.py b/esphome/components/valve/__init__.py index 692759f3e42..9ec21ef6aba 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -182,36 +182,36 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "valve.open", OpenAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.open", + OpenAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_open_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.close", CloseAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.close", + CloseAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_close_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.stop", StopAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.stop", + StopAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_stop_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action( - "valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA, synchronous=True +automation.register_simple_action( + "valve.toggle", + ToggleAction, + VALVE_ACTION_SCHEMA, + synchronous=True, ) -async def valve_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d66ef43fdc5..2f193ebb4e5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import register_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -14,8 +13,6 @@ 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"] @@ -424,29 +421,15 @@ automation.register_apply_action( ) -@register_condition( - "voice_assistant.is_running", IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_parented_condition( + "voice_assistant.is_running", + IsRunningCondition, + VOICE_ASSISTANT_ACTION_SCHEMA, ) -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 -@register_condition( - "voice_assistant.connected", ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA +automation.register_parented_condition( + "voice_assistant.connected", + ConnectedCondition, + VOICE_ASSISTANT_ACTION_SCHEMA, ) -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/wifi/__init__.py b/esphome/components/wifi/__init__.py index c22d49e6654..c9616a6c290 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -67,14 +67,13 @@ from esphome.const import ( ) from esphome.core import ( CORE, - ID, CoroPriority, EsphomeError, HexInt, coroutine_with_priority, ) import esphome.final_validate as fv -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import wpa2_eap @@ -830,45 +829,49 @@ async def to_code(config): CORE.add_job(final_step) -@automation.register_condition("wifi.connected", WiFiConnectedCondition, cv.Schema({})) -async def wifi_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.enabled", WiFiEnabledCondition, cv.Schema({})) -async def wifi_enabled_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) -async def wifi_ap_active_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.connected", + WiFiConnectedCondition, + cv.Schema({}), ) -async def wifi_enable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.enabled", + WiFiEnabledCondition, + cv.Schema({}), ) -async def wifi_disable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.ap_active", + WiFiAPActiveCondition, + cv.Schema({}), +) + + +automation.register_bare_action( + "wifi.enable", + WiFiEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.disable", + WiFiDisableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.roam", + WiFiRoamAction, + cv.Schema({}), + synchronous=True, ) -async def wifi_roam_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - return cg.new_Pvariable(action_id, template_arg) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 47913b34d70..8e9824d42e7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import only_on_variant from esphome.components.esp32.const import ( @@ -298,18 +298,9 @@ FactoryResetAction = zigbee_ns.class_( ) -@automation.register_action( +automation.register_parented_action( "zigbee.factory_reset", FactoryResetAction, ZIGBEE_ACTION_SCHEMA, synchronous=True, ) -async def reset_zigbee_to_code( - config: ConfigType, - action_id: core.ID, - template_arg: cg.TemplateArguments, - args: list[tuple], -) -> cg.Pvariable: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var From bb84409261282e81fd65e8e41757ffa7e36fc4f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 16:58:56 +0100 Subject: [PATCH 476/759] [sprinkler] Use register_apply_action for the seven keyless controller actions (#19617) --- esphome/components/sprinkler/__init__.py | 149 ++++++++-------------- esphome/components/sprinkler/automation.h | 70 ---------- 2 files changed, 55 insertions(+), 164 deletions(-) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index f14ea0cdeb9..00144adacd7 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -66,19 +66,10 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -ClearQueuedValvesAction = sprinkler_ns.class_( - "ClearQueuedValvesAction", automation.Action -) -StartFromQueueAction = sprinkler_ns.class_("StartFromQueueAction", automation.Action) -StartFullCycleAction = sprinkler_ns.class_("StartFullCycleAction", automation.Action) StartSingleValveAction = sprinkler_ns.class_( "StartSingleValveAction", automation.Action ) ShutdownAction = sprinkler_ns.class_("ShutdownAction", automation.Action) -NextValveAction = sprinkler_ns.class_("NextValveAction", automation.Action) -PreviousValveAction = sprinkler_ns.class_("PreviousValveAction", automation.Action) -PauseAction = sprinkler_ns.class_("PauseAction", automation.Action) -ResumeAction = sprinkler_ns.class_("ResumeAction", automation.Action) ResumeOrStartAction = sprinkler_ns.class_("ResumeOrStartAction", automation.Action) @@ -415,51 +406,61 @@ CONFIG_SCHEMA = cv.All( _VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) -automation.register_apply_action( - "sprinkler.set_divider", - SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_multiplier", - SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), -) - -automation.register_apply_action( - "sprinkler.queue_valve", - SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), -) - -automation.register_apply_action( - "sprinkler.set_repeat", - SPRINKLER_ACTION_REPEAT_SCHEMA, - automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_valve_run_duration", - SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), -) - - -automation.register_simple_action( - "sprinkler.start_from_queue", - StartFromQueueAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.start_full_cycle", - StartFullCycleAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) +for _name, _schema, _apply in ( + ( + "sprinkler.set_divider", + SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, + automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), + ), + ( + "sprinkler.set_multiplier", + SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, + automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), + ), + ( + "sprinkler.queue_valve", + SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, + automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.set_repeat", + SPRINKLER_ACTION_REPEAT_SCHEMA, + automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), + ), + ( + "sprinkler.set_valve_run_duration", + SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, + automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), + ), + ( + "sprinkler.start_from_queue", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_from_queue()"), + ), + ( + "sprinkler.start_full_cycle", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("start_full_cycle()"), + ), + ( + "sprinkler.clear_queued_valves", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("clear_queued_valves()"), + ), + ( + "sprinkler.next_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("next_valve()"), + ), + ( + "sprinkler.previous_valve", + SPRINKLER_ACTION_SCHEMA, + automation.ApplyCall("previous_valve()"), + ), + ("sprinkler.pause", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("pause()")), + ("sprinkler.resume", SPRINKLER_ACTION_SCHEMA, automation.ApplyCall("resume()")), +): + automation.register_apply_action(_name, _schema, _apply) @automation.register_action( @@ -479,46 +480,6 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, return var -automation.register_simple_action( - "sprinkler.clear_queued_valves", - ClearQueuedValvesAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.next_valve", - NextValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.previous_valve", - PreviousValveAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.pause", - PauseAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "sprinkler.resume", - ResumeAction, - SPRINKLER_ACTION_SCHEMA, - synchronous=True, -) - - automation.register_simple_action( "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index 14d9bd20ec1..1de0e1ee11e 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,36 +6,6 @@ namespace esphome::sprinkler { -template class ClearQueuedValvesAction final : public Action { - public: - explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->clear_queued_valves(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFromQueueAction final : public Action { - public: - explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_from_queue(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFullCycleAction final : public Action { - public: - explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_full_cycle(); } - - protected: - Sprinkler *sprinkler_; -}; - template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -64,46 +34,6 @@ template class ShutdownAction final : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction final : public Action { - public: - explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->next_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PreviousValveAction final : public Action { - public: - explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->previous_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PauseAction final : public Action { - public: - explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->pause(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class ResumeAction final : public Action { - public: - explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->resume(); } - - protected: - Sprinkler *sprinkler_; -}; - template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} From 1dcf0a0f9d119025c57d3831e9c128f477637356 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:00:33 +0100 Subject: [PATCH 477/759] [valve] Use register_apply_action for valve.open, valve.close, valve.stop and valve.toggle (#19618) --- esphome/components/valve/__init__.py | 47 ++++------------ esphome/components/valve/automation.h | 58 -------------------- tests/components/valve/common.yaml | 18 ++++++ tests/components/valve/test.esp32-idf.yaml | 2 + tests/components/valve/test.esp8266-ard.yaml | 2 + 5 files changed, 32 insertions(+), 95 deletions(-) create mode 100644 tests/components/valve/common.yaml create mode 100644 tests/components/valve/test.esp32-idf.yaml create mode 100644 tests/components/valve/test.esp8266-ard.yaml diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 9ec21ef6aba..7df58c7a2aa 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -1,5 +1,5 @@ from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -62,13 +62,7 @@ VALVE_OPERATIONS = { validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) # Actions -OpenAction = valve_ns.class_("OpenAction", automation.Action) -CloseAction = valve_ns.class_("CloseAction", automation.Action) -StopAction = valve_ns.class_("StopAction", automation.Action) -ToggleAction = valve_ns.class_("ToggleAction", automation.Action) ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) -ValveIsOpenCondition = valve_ns.class_("ValveIsOpenCondition", Condition) -ValveIsClosedCondition = valve_ns.class_("ValveIsClosedCondition", Condition) # Triggers ValveOpenTrigger = valve_ns.class_("ValveOpenTrigger", automation.Trigger.template()) @@ -182,36 +176,15 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "valve.open", - OpenAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.close", - CloseAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.stop", - StopAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "valve.toggle", - ToggleAction, - VALVE_ACTION_SCHEMA, - synchronous=True, -) +for _name, _command in ( + ("valve.open", "set_command_open()"), + ("valve.close", "set_command_close()"), + ("valve.stop", "set_command_stop()"), + ("valve.toggle", "set_command_toggle()"), +): + automation.register_apply_action( + _name, VALVE_ACTION_SCHEMA, automation.ApplyCall(_command), call="make_call" + ) VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 9fdb7665b3c..598bbc1678e 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,64 +6,6 @@ namespace esphome::valve { -template class OpenAction final : public Action { - public: - explicit OpenAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_open().perform(); } - - protected: - Valve *valve_; -}; - -template class CloseAction final : public Action { - public: - explicit CloseAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_close().perform(); } - - protected: - Valve *valve_; -}; - -template class StopAction final : public Action { - public: - explicit StopAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_stop().perform(); } - - protected: - Valve *valve_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Valve *valve) : valve_(valve) {} - - void play(const Ts &...x) override { this->valve_->make_call().set_command_toggle().perform(); } - - protected: - Valve *valve_; -}; - -template class ValveIsOpenCondition final : public Condition { - public: - ValveIsOpenCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } - - protected: - Valve *valve_; -}; - -template class ValveIsClosedCondition final : public Condition { - public: - ValveIsClosedCondition(Valve *valve) : valve_(valve) {} - bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } - - protected: - Valve *valve_; -}; - class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { diff --git a/tests/components/valve/common.yaml b/tests/components/valve/common.yaml new file mode 100644 index 00000000000..bf01f77bc9e --- /dev/null +++ b/tests/components/valve/common.yaml @@ -0,0 +1,18 @@ +valve: + - platform: template + name: Test Valve + id: test_valve + optimistic: true + has_position: true + +button: + - platform: template + name: Valve Commands + on_press: + - valve.open: test_valve + - valve.close: test_valve + - valve.stop: test_valve + - valve.toggle: test_valve + - valve.control: + id: test_valve + position: 50% diff --git a/tests/components/valve/test.esp32-idf.yaml b/tests/components/valve/test.esp32-idf.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml diff --git a/tests/components/valve/test.esp8266-ard.yaml b/tests/components/valve/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3c2acee7efa --- /dev/null +++ b/tests/components/valve/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + valve: !include common.yaml From 54d05180b7e07c6b5ab283b4bc87d13502869ef1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:30:10 +0100 Subject: [PATCH 478/759] [duty_time] Use one import style for the automation module (#19622) --- esphome/components/duty_time/sensor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index af7e14abb81..4afcf0a0ce6 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -1,5 +1,4 @@ from esphome import automation -from esphome.automation import Action, Condition, maybe_simple_id, register_condition import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv @@ -24,13 +23,13 @@ duty_time_sensor_ns = cg.esphome_ns.namespace("duty_time_sensor") DutyTimeSensor = duty_time_sensor_ns.class_( "DutyTimeSensor", sensor.Sensor, cg.PollingComponent ) -BaseAction = duty_time_sensor_ns.class_("BaseAction", Action, cg.Parented) +BaseAction = duty_time_sensor_ns.class_("BaseAction", automation.Action, cg.Parented) StartAction = duty_time_sensor_ns.class_("StartAction", BaseAction) StopAction = duty_time_sensor_ns.class_("StopAction", BaseAction) ResetAction = duty_time_sensor_ns.class_("ResetAction", BaseAction) SetAction = duty_time_sensor_ns.class_("SetAction", BaseAction) RunningCondition = duty_time_sensor_ns.class_( - "RunningCondition", Condition, cg.Parented + "RunningCondition", automation.Condition, cg.Parented ) @@ -81,7 +80,7 @@ async def to_code(config: ConfigType) -> None: # AUTOMATIONS -DUTY_TIME_ID_SCHEMA = maybe_simple_id( +DUTY_TIME_ID_SCHEMA = automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(DutyTimeSensor), } @@ -112,7 +111,7 @@ automation.register_parented_action( ) -@register_condition( +@automation.register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) async def duty_time_is_running_to_code( @@ -125,7 +124,7 @@ async def duty_time_is_running_to_code( return cg.new_Pvariable(condition_id, template_arg, paren, True) -@register_condition( +@automation.register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) async def duty_time_is_not_running_to_code( From 9a9169305e532d91aaca166655dad3515bd0974c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:31:34 +0100 Subject: [PATCH 479/759] [haier] Use register_apply_action for the thirteen climate.haier actions (#19624) --- esphome/components/haier/automation.h | 128 -------------- esphome/components/haier/climate.py | 165 ++++-------------- tests/components/haier/common-actions.yaml | 21 +++ .../haier/test-actions.esp8266-ard.yaml | 8 + 4 files changed, 63 insertions(+), 259 deletions(-) delete mode 100644 esphome/components/haier/automation.h create mode 100644 tests/components/haier/common-actions.yaml create mode 100644 tests/components/haier/test-actions.esp8266-ard.yaml diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h deleted file mode 100644 index a81fd4bdb7d..00000000000 --- a/esphome/components/haier/automation.h +++ /dev/null @@ -1,128 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "haier_base.h" -#include "hon_climate.h" - -namespace esphome::haier { - -template class DisplayOnAction final : public Action { - public: - DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class DisplayOffAction final : public Action { - public: - DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_display_state(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class BeeperOnAction final : public Action { - public: - BeeperOnAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(true); } - - protected: - HonClimate *parent_; -}; - -template class BeeperOffAction final : public Action { - public: - BeeperOffAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_beeper_state(false); } - - protected: - HonClimate *parent_; -}; - -template class VerticalAirflowAction final : public Action { - public: - VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_vertical_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HorizontalAirflowAction final : public Action { - public: - HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) - void play(const Ts &...x) { this->parent_->set_horizontal_airflow(this->direction_.value(x...)); } - - protected: - HonClimate *parent_; -}; - -template class HealthOnAction final : public Action { - public: - HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(true); } - - protected: - HaierClimateBase *parent_; -}; - -template class HealthOffAction final : public Action { - public: - HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->set_health_mode(false); } - - protected: - HaierClimateBase *parent_; -}; - -template class StartSelfCleaningAction final : public Action { - public: - StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_self_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class StartSteriCleaningAction final : public Action { - public: - StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } - - protected: - HonClimate *parent_; -}; - -template class PowerOnAction final : public Action { - public: - PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_on_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerOffAction final : public Action { - public: - PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->send_power_off_command(); } - - protected: - HaierClimateBase *parent_; -}; - -template class PowerToggleAction final : public Action { - public: - PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} - void play(const Ts &...x) { this->parent_->toggle_power(); } - - protected: - HaierClimateBase *parent_; -}; - -} // namespace esphome::haier diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 5a87ec1557a..5c4d498912d 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, - CONF_ID, CONF_LEVEL, CONF_LOGGER, CONF_LOGS, @@ -254,23 +253,6 @@ CONFIG_SCHEMA = cv.All( ) -# Actions -DisplayOnAction = haier_ns.class_("DisplayOnAction", automation.Action) -DisplayOffAction = haier_ns.class_("DisplayOffAction", automation.Action) -BeeperOnAction = haier_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = haier_ns.class_("BeeperOffAction", automation.Action) -StartSelfCleaningAction = haier_ns.class_("StartSelfCleaningAction", automation.Action) -StartSteriCleaningAction = haier_ns.class_( - "StartSteriCleaningAction", automation.Action -) -VerticalAirflowAction = haier_ns.class_("VerticalAirflowAction", automation.Action) -HorizontalAirflowAction = haier_ns.class_("HorizontalAirflowAction", automation.Action) -HealthOnAction = haier_ns.class_("HealthOnAction", automation.Action) -HealthOffAction = haier_ns.class_("HealthOffAction", automation.Action) -PowerOnAction = haier_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = haier_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = haier_ns.class_("PowerToggleAction", automation.Action) - HAIER_BASE_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(HaierClimateBase), @@ -284,59 +266,35 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "climate.haier.display_on", - DisplayOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) +for _name, _schema, _call in ( + ("climate.haier.display_on", HAIER_BASE_ACTION_SCHEMA, "set_display_state(true)"), + ("climate.haier.display_off", HAIER_BASE_ACTION_SCHEMA, "set_display_state(false)"), + ("climate.haier.beeper_on", HAIER_HON_BASE_ACTION_SCHEMA, "set_beeper_state(true)"), + ( + "climate.haier.beeper_off", + HAIER_HON_BASE_ACTION_SCHEMA, + "set_beeper_state(false)", + ), + ( + "climate.haier.start_self_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_self_cleaning()", + ), + ( + "climate.haier.start_steri_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_steri_cleaning()", + ), + ("climate.haier.health_on", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(true)"), + ("climate.haier.health_off", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(false)"), + ("climate.haier.power_on", HAIER_BASE_ACTION_SCHEMA, "send_power_on_command()"), + ("climate.haier.power_off", HAIER_BASE_ACTION_SCHEMA, "send_power_off_command()"), + ("climate.haier.power_toggle", HAIER_BASE_ACTION_SCHEMA, "toggle_power()"), +): + automation.register_apply_action(_name, _schema, automation.ApplyCall(_call)) - -automation.register_simple_action( - "climate.haier.display_off", - DisplayOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.beeper_on", - BeeperOnAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.beeper_off", - BeeperOffAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -# Start self cleaning or steri-cleaning action action -automation.register_simple_action( - "climate.haier.start_self_cleaning", - StartSelfCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.start_steri_cleaning", - StartSteriCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -# Set vertical airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_vertical_airflow", - VerticalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -345,22 +303,13 @@ automation.register_simple_action( ), } ), - synchronous=True, + automation.ApplyField( + CONF_VERTICAL_AIRFLOW, "set_vertical_airflow", AirflowVerticalDirection + ), ) -async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_VERTICAL_AIRFLOW], args, AirflowVerticalDirection - ) - cg.add(var.set_direction(template_)) - return var - -# Set horizontal airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_horizontal_airflow", - HorizontalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -369,55 +318,9 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), - synchronous=True, -) -async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_HORIZONTAL_AIRFLOW], args, AirflowHorizontalDirection - ) - cg.add(var.set_direction(template_)) - return var - - -automation.register_simple_action( - "climate.haier.health_on", - HealthOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.health_off", - HealthOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_on", - PowerOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_off", - PowerOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "climate.haier.power_toggle", - PowerToggleAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField( + CONF_HORIZONTAL_AIRFLOW, "set_horizontal_airflow", AirflowHorizontalDirection + ), ) diff --git a/tests/components/haier/common-actions.yaml b/tests/components/haier/common-actions.yaml new file mode 100644 index 00000000000..11b0902d79b --- /dev/null +++ b/tests/components/haier/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Haier Actions + on_press: + - climate.haier.display_on: haier_ac + - climate.haier.display_off: haier_ac + - climate.haier.beeper_on: haier_ac + - climate.haier.beeper_off: haier_ac + - climate.haier.start_self_cleaning: haier_ac + - climate.haier.start_steri_cleaning: haier_ac + - climate.haier.health_on: haier_ac + - climate.haier.health_off: haier_ac + - climate.haier.power_on: haier_ac + - climate.haier.power_off: haier_ac + - climate.haier.power_toggle: haier_ac + - climate.haier.set_vertical_airflow: + id: haier_ac + vertical_airflow: UP + - climate.haier.set_horizontal_airflow: + id: haier_ac + horizontal_airflow: !lambda return esphome::haier::hon_protocol::HorizontalSwingMode::LEFT; diff --git a/tests/components/haier/test-actions.esp8266-ard.yaml b/tests/components/haier/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..079cc9f6393 --- /dev/null +++ b/tests/components/haier/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + haier: !include common.yaml + actions: !include common-actions.yaml From 73548b0f15797b06e0b23d0f6f24d56eccbd602a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:32:16 +0100 Subject: [PATCH 480/759] [midea] Use register_apply_action for the eight midea_ac actions (#19625) --- esphome/components/midea/ac_automations.h | 66 --------- esphome/components/midea/climate.py | 130 ++++-------------- tests/components/midea/common-actions.yaml | 26 ++++ .../midea/test-actions.esp8266-ard.yaml | 9 ++ 4 files changed, 59 insertions(+), 172 deletions(-) delete mode 100644 esphome/components/midea/ac_automations.h create mode 100644 tests/components/midea/common-actions.yaml create mode 100644 tests/components/midea/test-actions.esp8266-ard.yaml diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h deleted file mode 100644 index 9572ec6c650..00000000000 --- a/esphome/components/midea/ac_automations.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) - -#include "esphome/core/automation.h" -#include "air_conditioner.h" - -namespace esphome::midea::ac { - -template class MideaActionBase : public Action { - public: - void set_parent(AirConditioner *parent) { this->parent_ = parent; } - - protected: - AirConditioner *parent_; -}; - -template class FollowMeAction : public MideaActionBase { - TEMPLATABLE_VALUE(float, temperature) - TEMPLATABLE_VALUE(bool, use_fahrenheit) - TEMPLATABLE_VALUE(bool, beeper) - - void play(const Ts &...x) override { - this->parent_->do_follow_me(this->temperature_.value(x...), this->use_fahrenheit_.value(x...), - this->beeper_.value(x...)); - } -}; - -template class SwingStepAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_swing_step(); } -}; - -template class DisplayToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_display_toggle(); } -}; - -template class BeeperOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_on(); } -}; - -template class BeeperOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_beeper_off(); } -}; - -template class PowerOnAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_on(); } -}; - -template class PowerOffAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_off(); } -}; - -template class PowerToggleAction : public MideaActionBase { - public: - void play(const Ts &...x) override { this->parent_->do_power_toggle(); } -}; - -} // namespace esphome::midea::ac - -#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 07ad02d3afc..e7a684babea 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import CORE, coroutine +from esphome.core import CORE CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -53,25 +53,6 @@ def templatize(value): return cv.Schema(ret) -def register_action(name, type_, schema): - validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action( - f"midea_ac.{name}", type_, validator, synchronous=True - ) - - def decorator(func): - async def new_func(config, action_id, template_arg, args): - ac_ = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(ac_)) - await coroutine(func)(var, config, args) - return var - - return registerer(new_func) - - return decorator - - ALLOWED_CLIMATE_MODES = { "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, "COOL": ClimateMode.CLIMATE_MODE_COOL, @@ -161,16 +142,6 @@ CONFIG_SCHEMA = cv.All( ), ) -# Actions -FollowMeAction = midea_ac_ns.class_("FollowMeAction", automation.Action) -DisplayToggleAction = midea_ac_ns.class_("DisplayToggleAction", automation.Action) -SwingStepAction = midea_ac_ns.class_("SwingStepAction", automation.Action) -BeeperOnAction = midea_ac_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = midea_ac_ns.class_("BeeperOffAction", automation.Action) -PowerOnAction = midea_ac_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = midea_ac_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = midea_ac_ns.class_("PowerToggleAction", automation.Action) - MIDEA_ACTION_BASE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(AirConditioner), @@ -187,84 +158,31 @@ MIDEA_FOLLOW_ME_SCHEMA = cv.Schema( ) -@register_action("follow_me", FollowMeAction, MIDEA_FOLLOW_ME_SCHEMA) -async def follow_me_to_code(var, config, args): - template_ = await cg.templatable(config[CONF_BEEPER], args, cg.bool_) - cg.add(var.set_beeper(template_)) - template_ = await cg.templatable(config[CONF_USE_FAHRENHEIT], args, cg.bool_) - cg.add(var.set_use_fahrenheit(template_)) - template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_temperature(template_)) - - -# Toggle Display action -@register_action( - "display_toggle", - DisplayToggleAction, - cv.Schema({}), +automation.register_apply_action( + "midea_ac.follow_me", + templatize(MIDEA_FOLLOW_ME_SCHEMA).extend(MIDEA_ACTION_BASE_SCHEMA), + automation.ApplyCall( + "do_follow_me({}, {}, {})", + ( + (CONF_TEMPERATURE, cg.float_), + (CONF_USE_FAHRENHEIT, cg.bool_), + (CONF_BEEPER, cg.bool_), + ), + ), ) -async def display_toggle_to_code(var, config, args): - pass - -# Swing Step action -@register_action( - "swing_step", - SwingStepAction, - cv.Schema({}), -) -async def swing_step_to_code(var, config, args): - pass - - -# Beeper On action -@register_action( - "beeper_on", - BeeperOnAction, - cv.Schema({}), -) -async def beeper_on_to_code(var, config, args): - pass - - -# Beeper Off action -@register_action( - "beeper_off", - BeeperOffAction, - cv.Schema({}), -) -async def beeper_off_to_code(var, config, args): - pass - - -# Power On action -@register_action( - "power_on", - PowerOnAction, - cv.Schema({}), -) -async def power_on_to_code(var, config, args): - pass - - -# Power Off action -@register_action( - "power_off", - PowerOffAction, - cv.Schema({}), -) -async def power_off_to_code(var, config, args): - pass - - -# Power Toggle action -@register_action( - "power_toggle", - PowerToggleAction, - cv.Schema({}), -) -async def power_inv_to_code(var, config, args): - pass +for _name, _call in ( + ("midea_ac.display_toggle", "do_display_toggle()"), + ("midea_ac.swing_step", "do_swing_step()"), + ("midea_ac.beeper_on", "do_beeper_on()"), + ("midea_ac.beeper_off", "do_beeper_off()"), + ("midea_ac.power_on", "do_power_on()"), + ("midea_ac.power_off", "do_power_off()"), + ("midea_ac.power_toggle", "do_power_toggle()"), +): + automation.register_apply_action( + _name, MIDEA_ACTION_BASE_SCHEMA, automation.ApplyCall(_call) + ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( diff --git a/tests/components/midea/common-actions.yaml b/tests/components/midea/common-actions.yaml new file mode 100644 index 00000000000..adda7ce3d9b --- /dev/null +++ b/tests/components/midea/common-actions.yaml @@ -0,0 +1,26 @@ +button: + - platform: template + name: Midea Actions + on_press: + - midea_ac.follow_me: + id: midea_unit + temperature: 23.0 + beeper: true + - midea_ac.follow_me: + id: midea_unit + temperature: !lambda return id(midea_unit).target_temperature + 1.0f; + use_fahrenheit: !lambda return false; + - midea_ac.display_toggle: + id: midea_unit + - midea_ac.swing_step: + id: midea_unit + - midea_ac.beeper_on: + id: midea_unit + - midea_ac.beeper_off: + id: midea_unit + - midea_ac.power_on: + id: midea_unit + - midea_ac.power_off: + id: midea_unit + - midea_ac.power_toggle: + id: midea_unit diff --git a/tests/components/midea/test-actions.esp8266-ard.yaml b/tests/components/midea/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..dcb7f75b9ed --- /dev/null +++ b/tests/components/midea/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea: !include common.yaml + actions: !include common-actions.yaml + +wifi: + ssid: MySSID + password: password1 From c598d2dee58378c198639feb510030f5074a515e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:32:46 +0100 Subject: [PATCH 481/759] [display_menu_base] Use the apply helpers for the eight display_menu actions and is_active (#19626) --- .../components/display_menu_base/__init__.py | 98 +++---------------- .../components/display_menu_base/automation.h | 88 ----------------- tests/components/lcd_menu/common-actions.yaml | 17 ++++ .../lcd_menu/test-actions.esp8266-ard.yaml | 11 +++ 4 files changed, 43 insertions(+), 171 deletions(-) create mode 100644 tests/components/lcd_menu/common-actions.yaml create mode 100644 tests/components/lcd_menu/test-actions.esp8266-ard.yaml diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index a5204cfe2fc..bb493ae8d33 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -55,18 +55,6 @@ MenuItemSwitch = display_menu_base_ns.class_("MenuItemSwitch") MenuItemCommand = display_menu_base_ns.class_("MenuItemCommand") MenuItemCustom = display_menu_base_ns.class_("MenuItemCustom") -UpAction = display_menu_base_ns.class_("UpAction", automation.Action) -DownAction = display_menu_base_ns.class_("DownAction", automation.Action) -LeftAction = display_menu_base_ns.class_("LeftAction", automation.Action) -RightAction = display_menu_base_ns.class_("RightAction", automation.Action) -EnterAction = display_menu_base_ns.class_("EnterAction", automation.Action) -ShowAction = display_menu_base_ns.class_("ShowAction", automation.Action) -HideAction = display_menu_base_ns.class_("HideAction", automation.Action) -ShowMainAction = display_menu_base_ns.class_("ShowMainAction", automation.Action) - -IsActiveCondition = display_menu_base_ns.class_( - "IsActiveCondition", automation.Condition -) MenuItemType = display_menu_base_ns.enum("MenuItemType") @@ -294,78 +282,22 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "display_menu.up", - UpAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("display_menu.up", "up()"), + ("display_menu.down", "down()"), + ("display_menu.left", "left()"), + ("display_menu.right", "right()"), + ("display_menu.enter", "enter()"), + ("display_menu.show", "show()"), + ("display_menu.hide", "hide()"), + ("display_menu.show_main", "show_main()"), +): + automation.register_apply_action( + _name, MENU_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -automation.register_simple_action( - "display_menu.down", - DownAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.left", - LeftAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.right", - RightAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.enter", - EnterAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.show", - ShowAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.hide", - HideAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "display_menu.show_main", - ShowMainAction, - MENU_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_condition( - "display_menu.is_active", - IsActiveCondition, - automation.maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.use_id(DisplayMenuComponent), - } - ), +automation.register_apply_condition( + "display_menu.is_active", MENU_ACTION_SCHEMA, "is_active()" ) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index be0044ffa4d..477a971d479 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,94 +5,6 @@ namespace esphome::display_menu_base { -template class UpAction final : public Action { - public: - explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->up(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class DownAction final : public Action { - public: - explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->down(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class LeftAction final : public Action { - public: - explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->left(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class RightAction final : public Action { - public: - explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->right(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class EnterAction final : public Action { - public: - explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->enter(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowAction final : public Action { - public: - explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class HideAction final : public Action { - public: - explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->hide(); } - - protected: - DisplayMenuComponent *menu_; -}; - -template class ShowMainAction final : public Action { - public: - explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} - - void play(const Ts &...x) override { this->menu_->show_main(); } - - protected: - DisplayMenuComponent *menu_; -}; -template class IsActiveCondition final : public Condition { - public: - explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} - bool check(const Ts &...x) override { return this->menu_->is_active(); } - - protected: - DisplayMenuComponent *menu_; -}; - class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { diff --git a/tests/components/lcd_menu/common-actions.yaml b/tests/components/lcd_menu/common-actions.yaml new file mode 100644 index 00000000000..9c697a7a363 --- /dev/null +++ b/tests/components/lcd_menu/common-actions.yaml @@ -0,0 +1,17 @@ +button: + - platform: template + name: Menu Actions + on_press: + - display_menu.show: test_lcd_menu + - display_menu.up: test_lcd_menu + - display_menu.down: test_lcd_menu + - display_menu.left: test_lcd_menu + - display_menu.right: test_lcd_menu + - display_menu.enter: test_lcd_menu + - if: + condition: + display_menu.is_active: test_lcd_menu + then: + - display_menu.hide: test_lcd_menu + else: + - display_menu.show_main: test_lcd_menu diff --git a/tests/components/lcd_menu/test-actions.esp8266-ard.yaml b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..49b48563c3a --- /dev/null +++ b/tests/components/lcd_menu/test-actions.esp8266-ard.yaml @@ -0,0 +1,11 @@ +substitutions: + d0_pin: GPIO0 + d1_pin: GPIO2 + d2_pin: GPIO14 + d3_pin: GPIO15 + enable_pin: GPIO16 + rs_pin: GPIO5 + +packages: + lcd_menu: !include common.yaml + actions: !include common-actions.yaml From 96d0d677b66536cb002f0f615658a97c809bcdcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:33:35 +0100 Subject: [PATCH 482/759] [alarm_control_panel] Use register_apply_action for pending and triggered, drop the chime and ready actions that had no class (#19627) --- .../alarm_control_panel/__init__.py | 44 +++++-------------- .../alarm_control_panel/automation.h | 20 --------- .../alarm_control_panel/common-actions.yaml | 6 +++ .../test-actions.esp8266-ard.yaml | 3 ++ 4 files changed, 19 insertions(+), 54 deletions(-) create mode 100644 tests/components/alarm_control_panel/common-actions.yaml create mode 100644 tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b0fea438017..37b27b5391e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -41,10 +41,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -PendingAction = alarm_control_panel_ns.class_("PendingAction", automation.Action) -TriggeredAction = alarm_control_panel_ns.class_("TriggeredAction", automation.Action) -ChimeAction = alarm_control_panel_ns.class_("ChimeAction", automation.Action) -ReadyAction = alarm_control_panel_ns.class_("ReadyAction", automation.Action) AlarmControlPanelCondition = alarm_control_panel_ns.class_( "AlarmControlPanelCondition", automation.Condition @@ -208,36 +204,16 @@ for _name, _arm in ( ) -automation.register_simple_action( - "alarm_control_panel.pending", - PendingAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.triggered", - TriggeredAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.chime", - ChimeAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "alarm_control_panel.ready", - ReadyAction, - ALARM_CONTROL_PANEL_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("alarm_control_panel.pending", "pending()"), + ("alarm_control_panel.triggered", "triggered()"), +): + automation.register_apply_action( + _name, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + automation.ApplyCall(_call), + call="make_call", + ) automation.register_simple_condition( diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index cd9b5eadced..7a683086a46 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,26 +27,6 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class PendingAction final : public Action { - public: - explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().pending().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - -template class TriggeredAction final : public Action { - public: - explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} - - void play(const Ts &...x) override { this->alarm_control_panel_->make_call().triggered().perform(); } - - protected: - AlarmControlPanel *alarm_control_panel_; -}; - template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} diff --git a/tests/components/alarm_control_panel/common-actions.yaml b/tests/components/alarm_control_panel/common-actions.yaml new file mode 100644 index 00000000000..1ba7342c891 --- /dev/null +++ b/tests/components/alarm_control_panel/common-actions.yaml @@ -0,0 +1,6 @@ +button: + - platform: template + name: Alarm Calls + on_press: + - alarm_control_panel.pending: alarmcontrolpanel1 + - alarm_control_panel.triggered: alarmcontrolpanel1 diff --git a/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..ce3259f91fc --- /dev/null +++ b/tests/components/alarm_control_panel/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + alarm_control_panel: !include common.yaml + actions: !include common-actions.yaml From 7294da9f490d89bebe0f11651f25dfa44ac7508e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 17:34:39 +0100 Subject: [PATCH 483/759] [integration] Use register_apply_action for sensor.integration.reset and set_value (#19628) --- .../integration/integration_sensor.h | 12 --------- esphome/components/integration/sensor.py | 26 +++---------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 019c3ee0740..22d664cd555 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -71,16 +71,4 @@ class IntegrationSensor final : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetValueAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { this->parent_->set_value(this->value_.value(x...)); } -}; - } // namespace esphome::integration diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 672af46e3e3..a68cb575192 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,17 +11,13 @@ 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_( "IntegrationSensor", sensor.Sensor, cg.Component ) -ResetAction = integration_ns.class_("ResetAction", automation.Action) -SetValueAction = integration_ns.class_("SetValueAction", automation.Action) IntegrationSensorTime = integration_ns.enum("IntegrationSensorTime") INTEGRATION_TIMES = { @@ -106,37 +102,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -automation.register_parented_action( +automation.register_apply_action( "sensor.integration.reset", - ResetAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) - -@automation.register_action( +automation.register_apply_action( "sensor.integration.set_value", - SetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -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_) - cg.add(var.set_value(template_)) - return var From c561c6bbe8c9c5959edec59e8b9858fd8795d618 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:02:58 +0100 Subject: [PATCH 484/759] [scd4x] Use register_apply_action for perform_forced_calibration and factory_reset (#19630) --- esphome/components/scd4x/automation.h | 31 ------------------- esphome/components/scd4x/sensor.py | 29 +++-------------- tests/components/scd4x/common-actions.yaml | 11 +++++++ .../scd4x/test-actions.esp8266-ard.yaml | 4 +++ 4 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 esphome/components/scd4x/automation.h create mode 100644 tests/components/scd4x/common-actions.yaml create mode 100644 tests/components/scd4x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h deleted file mode 100644 index e0cc04e2cb7..00000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd4x.h" - -namespace esphome::scd4x { - -template -class PerformForcedCalibrationAction final : public Action, public Parented { - public: - // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already - // zero. - PerformForcedCalibrationAction() {} - - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->perform_forced_calibration(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index fcae9e670ed..71be6e43b16 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -26,8 +26,6 @@ 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"] @@ -47,12 +45,6 @@ MEASUREMENT_MODE_OPTIONS = { } -# Actions -PerformForcedCalibrationAction = scd4x_ns.class_( - "PerformForcedCalibrationAction", automation.Action -) -FactoryResetAction = scd4x_ns.class_("FactoryResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -140,23 +132,11 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "scd4x.perform_forced_calibration", - PerformForcedCalibrationAction, SCD4X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VALUE, "perform_forced_calibration", cg.uint16), ) -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) - cg.add(var.set_value(template_)) - return var SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( @@ -166,9 +146,8 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( +automation.register_apply_action( "scd4x.factory_reset", - FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) diff --git a/tests/components/scd4x/common-actions.yaml b/tests/components/scd4x/common-actions.yaml new file mode 100644 index 00000000000..20c30e2cada --- /dev/null +++ b/tests/components/scd4x/common-actions.yaml @@ -0,0 +1,11 @@ +button: + - platform: template + name: SCD4x Actions + on_press: + - scd4x.perform_forced_calibration: + id: scd40 + value: 419 + - scd4x.perform_forced_calibration: + id: scd40 + value: !lambda return 420; + - scd4x.factory_reset: scd40 diff --git a/tests/components/scd4x/test-actions.esp8266-ard.yaml b/tests/components/scd4x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5bf817cb86 --- /dev/null +++ b/tests/components/scd4x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd4x: !include common.yaml + actions: !include common-actions.yaml From eb85778a6044932aaafe321118bdff1f291d4551 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:07:15 +0100 Subject: [PATCH 485/759] [mhz19] Use register_apply_action for the four mhz19 actions (#19631) --- esphome/components/mhz19/mhz19.h | 24 ------- esphome/components/mhz19/sensor.py | 64 +++---------------- tests/components/mhz19/common-actions.yaml | 10 +++ tests/components/mhz19/common.yaml | 1 + .../mhz19/test-actions.esp8266-ard.yaml | 8 +++ 5 files changed, 29 insertions(+), 78 deletions(-) create mode 100644 tests/components/mhz19/common-actions.yaml create mode 100644 tests/components/mhz19/test-actions.esp8266-ard.yaml diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index 3cef3a3930d..0aacdda8886 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -49,27 +48,4 @@ class MHZ19Component final : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_zero(); } -}; - -template class MHZ19ABCEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_enable(); } -}; - -template class MHZ19ABCDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->abc_disable(); } -}; - -template -class MHZ19DetectionRangeSetAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) - - void play(const Ts &...x) override { this->parent_->range_set(this->detection_range_.value(x...)); } -}; - } // namespace esphome::mhz19 diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4434eddb08d..9b0eeeb28ea 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,8 +15,6 @@ 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"] @@ -26,21 +24,6 @@ CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") MHZ19Component = mhz19_ns.class_("MHZ19Component", cg.PollingComponent, uart.UARTDevice) -MHZ19CalibrateZeroAction = mhz19_ns.class_( - "MHZ19CalibrateZeroAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCEnableAction = mhz19_ns.class_( - "MHZ19ABCEnableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19ABCDisableAction = mhz19_ns.class_( - "MHZ19ABCDisableAction", automation.Action, cg.Parented.template(MHZ19Component) -) -MHZ19DetectionRangeSetAction = mhz19_ns.class_( - "MHZ19DetectionRangeSetAction", - automation.Action, - cg.Parented.template(MHZ19Component), -) - mhz19_detection_range = mhz19_ns.enum("MHZ19DetectionRange") MHZ19_DETECTION_RANGE_ENUM = { 2000: mhz19_detection_range.MHZ19_DETECTION_RANGE_0_2000PPM, @@ -122,28 +105,14 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "mhz19.calibrate_zero", - MHZ19CalibrateZeroAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "mhz19.abc_enable", - MHZ19ABCEnableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "mhz19.abc_disable", - MHZ19ABCDisableAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("mhz19.calibrate_zero", "calibrate_zero()"), + ("mhz19.abc_enable", "abc_enable()"), + ("mhz19.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) RANGE_ACTION_SCHEMA = maybe_simple_id( @@ -156,21 +125,8 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "mhz19.detection_range_set", - MHZ19DetectionRangeSetAction, RANGE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_DETECTION_RANGE, "range_set", mhz19_detection_range), ) -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) - template_ = await cg.templatable(detection_range, args, mhz19_detection_range) - cg.add(var.set_detection_range(template_)) - return var diff --git a/tests/components/mhz19/common-actions.yaml b/tests/components/mhz19/common-actions.yaml new file mode 100644 index 00000000000..2213019da72 --- /dev/null +++ b/tests/components/mhz19/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MH-Z19 Actions + on_press: + - mhz19.calibrate_zero: mhz19_sensor + - mhz19.abc_enable: mhz19_sensor + - mhz19.abc_disable: mhz19_sensor + - mhz19.detection_range_set: + id: mhz19_sensor + detection_range: 2000ppm diff --git a/tests/components/mhz19/common.yaml b/tests/components/mhz19/common.yaml index b12ca501975..7f5e13b5650 100644 --- a/tests/components/mhz19/common.yaml +++ b/tests/components/mhz19/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: mhz19 + id: mhz19_sensor co2: name: MH-Z19 CO2 Value temperature: diff --git a/tests/components/mhz19/test-actions.esp8266-ard.yaml b/tests/components/mhz19/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..b3e895ffc86 --- /dev/null +++ b/tests/components/mhz19/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + mhz19: !include common.yaml + actions: !include common-actions.yaml From 4bb6c0f877ea03d675f18d938f68d86ff9bb836a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:07:48 +0100 Subject: [PATCH 486/759] [update] Use the apply helpers for update.check and update.is_available (#19632) --- esphome/components/update/__init__.py | 34 ++++++++------------------ esphome/components/update/automation.h | 19 -------------- 2 files changed, 10 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/update/automation.h diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 87707159893..1533a893ba6 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -32,13 +32,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -CheckAction = update_ns.class_( - "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) -) -IsAvailableCondition = update_ns.class_( - "IsAvailableCondition", automation.Condition, cg.Parented.template(UpdateEntity) -) - DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_FIRMWARE, @@ -144,24 +137,17 @@ automation.register_apply_action( ) -automation.register_parented_action( - "update.check", - CheckAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), - synchronous=True, +UPDATE_AUTOMATION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(UpdateEntity), + } ) - -automation.register_parented_condition( +automation.register_apply_action( + "update.check", UPDATE_AUTOMATION_SCHEMA, automation.ApplyCall("check()") +) +automation.register_apply_condition( "update.is_available", - IsAvailableCondition, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(UpdateEntity), - } - ), + UPDATE_AUTOMATION_SCHEMA, + "state == update::UPDATE_STATE_AVAILABLE", ) diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h deleted file mode 100644 index 3d303fdb639..00000000000 --- a/esphome/components/update/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "update_entity.h" - -#include "esphome/core/automation.h" - -namespace esphome::update { - -template class CheckAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->check(); } -}; - -template class IsAvailableCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } -}; - -} // namespace esphome::update From c73a71e037355c8b69a8b0a0e0dfa04a32706037 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:08:18 +0100 Subject: [PATCH 487/759] [animation] Use register_apply_action for next_frame, prev_frame and set_frame (#19633) --- esphome/components/animation/animation.h | 30 -------------- esphome/components/animation/image.py | 40 ++++--------------- .../components/animation/common-actions.yaml | 9 +++++ .../animation/test-actions.esp8266-ard.yaml | 14 +++++++ 4 files changed, 31 insertions(+), 62 deletions(-) create mode 100644 tests/components/animation/common-actions.yaml create mode 100644 tests/components/animation/test-actions.esp8266-ard.yaml diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 64cddbf09c9..4ee060c1213 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -1,8 +1,6 @@ #pragma once #include "esphome/components/image/image.h" -#include "esphome/core/automation.h" - namespace esphome::animation { class Animation final : public image::Image { @@ -35,32 +33,4 @@ class Animation final : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction final : public Action { - public: - AnimationNextFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->next_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationPrevFrameAction final : public Action { - public: - AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} - void play(const Ts &...x) override { this->parent_->prev_frame(); } - - protected: - Animation *parent_; -}; - -template class AnimationSetFrameAction final : public Action { - public: - AnimationSetFrameAction(Animation *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(uint16_t, frame) - void play(const Ts &...x) override { this->parent_->set_frame(this->frame_.value(x...)); } - - protected: - Animation *parent_; -}; - } // namespace esphome::animation diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 0265a350f71..09e0e8753cf 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -6,8 +6,6 @@ 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"] @@ -26,17 +24,6 @@ animation_ns = cg.esphome_ns.namespace("animation") Animation_ = animation_ns.class_("Animation", Image_) -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) -) - ANIMATION_SCHEMA = image_schema(Animation_).extend( { cv.Optional(CONF_LOOP): cv.All( @@ -72,28 +59,17 @@ SET_FRAME_SCHEMA = cv.Schema( ) -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.next_frame", NEXT_FRAME_SCHEMA, automation.ApplyCall("next_frame()") ) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.prev_frame", PREV_FRAME_SCHEMA, automation.ApplyCall("prev_frame()") ) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +automation.register_apply_action( + "animation.set_frame", + SET_FRAME_SCHEMA, + automation.ApplyField(CONF_FRAME, "set_frame", cg.uint16), ) -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) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var async def setup_animation(config: ConfigType) -> None: diff --git a/tests/components/animation/common-actions.yaml b/tests/components/animation/common-actions.yaml new file mode 100644 index 00000000000..45812374838 --- /dev/null +++ b/tests/components/animation/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: Animation Actions + on_press: + - animation.next_frame: rgb_animation + - animation.prev_frame: rgb_animation + - animation.set_frame: + id: rgb_animation + frame: 2 diff --git a/tests/components/animation/test-actions.esp8266-ard.yaml b/tests/components/animation/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..0d8a73f8bda --- /dev/null +++ b/tests/components/animation/test-actions.esp8266-ard.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + animation: !include common.yaml + actions: !include common-actions.yaml + +display: + - platform: ili9xxx + id: animation_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 5 + dc_pin: 15 + reset_pin: 16 + invert_colors: false From d6831c8c754d5c5495dc423311bbc457ef59665f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:13:46 +0100 Subject: [PATCH 488/759] [duty_time] Use the apply helpers for the start, stop, reset and is_running automations (#19634) --- .../components/duty_time/duty_time_sensor.h | 24 ------- esphome/components/duty_time/sensor.py | 69 ++++--------------- .../components/duty_time/common-actions.yaml | 15 ++++ tests/components/duty_time/common.yaml | 1 + .../duty_time/test-actions.esp8266-ard.yaml | 3 + 5 files changed, 32 insertions(+), 80 deletions(-) create mode 100644 tests/components/duty_time/common-actions.yaml create mode 100644 tests/components/duty_time/test-actions.esp8266-ard.yaml diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index a9e91de0b19..9b41ac7e0f6 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -2,7 +2,6 @@ #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" #include "esphome/components/sensor/sensor.h" @@ -47,27 +46,4 @@ class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { bool restore_; }; -template class BaseAction : public Action, public Parented {}; - -template class StartAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class ResetAction : public BaseAction { - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class RunningCondition final : public Condition, public Parented { - public: - explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} - - protected: - bool check(const Ts &...x) override { return this->parent_->is_running() == this->state_; } - bool state_; -}; - } // namespace esphome::duty_time_sensor diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 4afcf0a0ce6..7fd01db8360 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -13,8 +13,6 @@ 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" @@ -23,14 +21,6 @@ duty_time_sensor_ns = cg.esphome_ns.namespace("duty_time_sensor") DutyTimeSensor = duty_time_sensor_ns.class_( "DutyTimeSensor", sensor.Sensor, cg.PollingComponent ) -BaseAction = duty_time_sensor_ns.class_("BaseAction", automation.Action, cg.Parented) -StartAction = duty_time_sensor_ns.class_("StartAction", BaseAction) -StopAction = duty_time_sensor_ns.class_("StopAction", BaseAction) -ResetAction = duty_time_sensor_ns.class_("ResetAction", BaseAction) -SetAction = duty_time_sensor_ns.class_("SetAction", BaseAction) -RunningCondition = duty_time_sensor_ns.class_( - "RunningCondition", automation.Condition, cg.Parented -) CONFIG_SCHEMA = cv.All( @@ -87,51 +77,18 @@ DUTY_TIME_ID_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sensor.duty_time.start", - StartAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, +for _name, _call in ( + ("sensor.duty_time.start", "start()"), + ("sensor.duty_time.stop", "stop()"), + ("sensor.duty_time.reset", "reset()"), +): + automation.register_apply_action( + _name, DUTY_TIME_ID_SCHEMA, automation.ApplyCall(_call) + ) + +automation.register_apply_condition( + "sensor.duty_time.is_running", DUTY_TIME_ID_SCHEMA, "is_running()" ) - - -automation.register_parented_action( - "sensor.duty_time.stop", - StopAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, +automation.register_apply_condition( + "sensor.duty_time.is_not_running", DUTY_TIME_ID_SCHEMA, "is_running() == false" ) - - -automation.register_parented_action( - "sensor.duty_time.reset", - ResetAction, - DUTY_TIME_ID_SCHEMA, - synchronous=True, -) - - -@automation.register_condition( - "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -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) - - -@automation.register_condition( - "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA -) -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/tests/components/duty_time/common-actions.yaml b/tests/components/duty_time/common-actions.yaml new file mode 100644 index 00000000000..18b1a7b6aac --- /dev/null +++ b/tests/components/duty_time/common-actions.yaml @@ -0,0 +1,15 @@ +button: + - platform: template + name: Duty Time Actions + on_press: + - if: + condition: + sensor.duty_time.is_running: pump_duty_time + then: + - sensor.duty_time.stop: pump_duty_time + - if: + condition: + sensor.duty_time.is_not_running: pump_duty_time + then: + - sensor.duty_time.start: pump_duty_time + - sensor.duty_time.reset: pump_duty_time diff --git a/tests/components/duty_time/common.yaml b/tests/components/duty_time/common.yaml index 12e4397c491..a2a710e2d95 100644 --- a/tests/components/duty_time/common.yaml +++ b/tests/components/duty_time/common.yaml @@ -9,5 +9,6 @@ binary_sensor: sensor: - platform: duty_time + id: pump_duty_time name: Duty Time sensor: duty_time_bin1 diff --git a/tests/components/duty_time/test-actions.esp8266-ard.yaml b/tests/components/duty_time/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..bc514ad4c96 --- /dev/null +++ b/tests/components/duty_time/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + duty_time: !include common.yaml + actions: !include common-actions.yaml From bdb492c196a15a907b2d801ca6257843fa6c07a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:15:13 +0100 Subject: [PATCH 489/759] [sx127x] Use register_apply_action for the five mode and calibration actions (#19636) --- esphome/components/sx127x/__init__.py | 63 ++++---------------------- esphome/components/sx127x/automation.h | 25 ---------- 2 files changed, 10 insertions(+), 78 deletions(-) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 5701f2acb7c..9f95c618445 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -120,24 +120,9 @@ SHAPING = { "NONE": SX127xPaRamp.SHAPING_NONE, } -RunImageCalAction = sx127x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX127x) -) SendPacketAction = sx127x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX127x) ) -SetModeTxAction = sx127x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeRxAction = sx127x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeSleepAction = sx127x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX127x) -) -SetModeStandbyAction = sx127x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX127x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -286,44 +271,16 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sx127x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_sleep", - SetModeSleepAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx127x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("sx127x.run_image_cal", "run_image_cal()"), + ("sx127x.set_mode_tx", "set_mode_tx()"), + ("sx127x.set_mode_rx", "set_mode_rx()"), + ("sx127x.set_mode_sleep", "set_mode_sleep()"), + ("sx127x.set_mode_standby", "set_mode_standby()"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index f6a4537e231..2ed16146813 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx127x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,24 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(); } -}; - } // namespace esphome::sx127x From 11e016be5989fc3804b501cc39eb8d3612527a23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:16:06 +0100 Subject: [PATCH 490/759] [sx126x] Use register_apply_action for the five mode and calibration actions (#19635) --- esphome/components/sx126x/__init__.py | 72 +++---------------- esphome/components/sx126x/automation.h | 26 ------- tests/components/sx126x/common-actions.yaml | 9 +++ .../sx126x/test-actions.esp8266-ard.yaml | 13 ++++ 4 files changed, 33 insertions(+), 87 deletions(-) create mode 100644 tests/components/sx126x/common-actions.yaml create mode 100644 tests/components/sx126x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index f8f51296a16..ce47a570501 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -131,24 +131,9 @@ SHAPING = { "NONE": SX126xPulseShape.NO_FILTER, } -RunImageCalAction = sx126x_ns.class_( - "RunImageCalAction", automation.Action, cg.Parented.template(SX126x) -) SendPacketAction = sx126x_ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(SX126x) ) -SetModeTxAction = sx126x_ns.class_( - "SetModeTxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeRxAction = sx126x_ns.class_( - "SetModeRxAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeSleepAction = sx126x_ns.class_( - "SetModeSleepAction", automation.Action, cg.Parented.template(SX126x) -) -SetModeStandbyAction = sx126x_ns.class_( - "SetModeStandbyAction", automation.Action, cg.Parented.template(SX126x) -) def validate_raw_data(value: Any) -> bytes | list[int]: @@ -302,37 +287,15 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sx126x.run_image_cal", - RunImageCalAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_tx", - SetModeTxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_rx", - SetModeRxAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sx126x.set_mode_standby", - SetModeStandbyAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - +for _name, _call in ( + ("sx126x.run_image_cal", "run_image_cal()"), + ("sx126x.set_mode_tx", "set_mode_tx()"), + ("sx126x.set_mode_rx", "set_mode_rx()"), + ("sx126x.set_mode_standby", "set_mode_standby(sx126x::STDBY_XOSC)"), +): + automation.register_apply_action( + _name, NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_call) + ) SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( { @@ -341,24 +304,11 @@ SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@automation.register_action( +automation.register_apply_action( "sx126x.set_mode_sleep", - SetModeSleepAction, SET_MODE_SLEEP_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COLD, "set_mode_sleep", cg.bool_), ) -async def set_mode_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]) - template_ = await cg.templatable(config[CONF_COLD], args, bool) - cg.add(var.set_cold(template_)) - return var SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 4eb33abaa1e..411de123413 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,11 +6,6 @@ namespace esphome::sx126x { -template class RunImageCalAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->run_image_cal(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { @@ -43,25 +38,4 @@ template class SendPacketAction final : public Action, pu } data_; }; -template class SetModeTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_tx(); } -}; - -template class SetModeRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_rx(); } -}; - -template class SetModeSleepAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, cold) - void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } -}; - -template class SetModeStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } -}; - } // namespace esphome::sx126x diff --git a/tests/components/sx126x/common-actions.yaml b/tests/components/sx126x/common-actions.yaml new file mode 100644 index 00000000000..2612095cc60 --- /dev/null +++ b/tests/components/sx126x/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: SX126x Mode Actions + on_press: + - sx126x.set_mode_tx + - sx126x.set_mode_sleep: + cold: true + - sx126x.set_mode_sleep: + cold: !lambda return id(sx126x_my_number).state > 1; diff --git a/tests/components/sx126x/test-actions.esp8266-ard.yaml b/tests/components/sx126x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d412d58967 --- /dev/null +++ b/tests/components/sx126x/test-actions.esp8266-ard.yaml @@ -0,0 +1,13 @@ +substitutions: + clk_pin: GPIO0 + mosi_pin: GPIO15 + miso_pin: GPIO16 + cs_pin: GPIO1 + rst_pin: GPIO2 + busy_pin: GPIO4 + dio1_pin: GPIO3 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sx126x: !include common.yaml + actions: !include common-actions.yaml From 23c317f163bbf18104dfe24136ce6c2347bc0fb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:43:10 +0100 Subject: [PATCH 491/759] [pcf8563] Use register_apply_action for pcf8563.write_time and pcf8563.read_time (#19639) --- esphome/components/pcf8563/pcf8563.h | 9 --------- esphome/components/pcf8563/time.py | 30 +++++++++------------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index e208774c2c6..2921d4f0475 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -109,13 +109,4 @@ class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice } pcf8563_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf8563 diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 141949bf35d..3f677280c5b 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -14,8 +14,6 @@ pcf8563_ns = cg.esphome_ns.namespace("pcf8563") pcf8563Component = pcf8563_ns.class_( "PCF8563Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf8563_ns.class_("WriteAction", automation.Action) -ReadAction = pcf8563_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -25,27 +23,17 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -automation.register_parented_action( - "pcf8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +PCF8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(pcf8563Component), + } ) - -automation.register_parented_action( - "pcf8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(pcf8563Component), - } - ), - synchronous=True, +automation.register_apply_action( + "pcf8563.write_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "pcf8563.read_time", PCF8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) From 0cb421f79398315731918115c147f7eaf2a3db78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:45:38 +0100 Subject: [PATCH 492/759] [max7219digit] Use register_apply_action for the seven max7219digit actions (#19641) --- esphome/components/max7219digit/automation.h | 50 --------- esphome/components/max7219digit/display.py | 110 +++---------------- 2 files changed, 13 insertions(+), 147 deletions(-) delete mode 100644 esphome/components/max7219digit/automation.h diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h deleted file mode 100644 index f06dfd5087a..00000000000 --- a/esphome/components/max7219digit/automation.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -#include "max7219digit.h" - -namespace esphome::max7219digit { - -template class DisplayInvertAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->invert_on_off(state); - } -}; - -template class DisplayVisibilityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->turn_on_off(state); - } -}; - -template class DisplayReverseAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - bool state = this->state_.value(x...); - this->parent_->set_reverse(state); - } -}; - -template class DisplayIntensityAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - - void play(const Ts &...x) override { - uint8_t state = this->state_.value(x...); - this->parent_->set_intensity(state); - } -}; - -} // namespace esphome::max7219digit diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 54711263dda..7974ef20202 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,8 +10,6 @@ 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"] @@ -112,14 +110,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -DisplayInvertAction = max7219_ns.class_("DisplayInvertAction", automation.Action) -DisplayVisibilityAction = max7219_ns.class_( - "DisplayVisibilityAction", automation.Action -) -DisplayReverseAction = max7219_ns.class_("DisplayReverseAction", automation.Action) -DisplayIntensityAction = max7219_ns.class_("DisplayIntensityAction", automation.Action) - - MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(MAX7219Component), @@ -135,79 +125,17 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "max7219digit.invert_off", - DisplayInvertAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.invert_on", - DisplayInvertAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -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_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.turn_off", - DisplayVisibilityAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.turn_on", - DisplayVisibilityAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -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_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( - "max7219digit.reverse_off", - DisplayReverseAction, - MAX7219_OFF_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "max7219digit.reverse_on", - DisplayReverseAction, - MAX7219_ON_ACTION_SCHEMA, - synchronous=True, -) -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_) - cg.add(var.set_state(template_)) - return var +for _name, _schema, _method in ( + ("max7219digit.invert_off", MAX7219_OFF_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.invert_on", MAX7219_ON_ACTION_SCHEMA, "invert_on_off"), + ("max7219digit.turn_off", MAX7219_OFF_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.turn_on", MAX7219_ON_ACTION_SCHEMA, "turn_on_off"), + ("max7219digit.reverse_off", MAX7219_OFF_ACTION_SCHEMA, "set_reverse"), + ("max7219digit.reverse_on", MAX7219_ON_ACTION_SCHEMA, "set_reverse"), +): + automation.register_apply_action( + _name, _schema, automation.ApplyField(CONF_STATE, _method, cg.bool_) + ) MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @@ -221,20 +149,8 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "max7219digit.intensity", - DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_INTENSITY, "set_intensity", cg.uint8), ) -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) - cg.add(var.set_state(template_)) - return var From a088eff4289a99a2ee95de70be2449931643f692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 18:53:02 +0100 Subject: [PATCH 493/759] [binary_sensor] Use register_apply_action for binary_sensor.invalidate_state (#19637) --- esphome/components/binary_sensor/__init__.py | 8 ++------ esphome/components/binary_sensor/automation.h | 10 ---------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 696d7345ddc..6b7c3dcb163 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -134,9 +134,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorInvalidateAction = binary_sensor_ns.class_( - "BinarySensorInvalidateAction", automation.Action -) # Filters Filter = binary_sensor_ns.class_("Filter") @@ -655,16 +652,15 @@ async def to_code(config): cg.add_global(binary_sensor_ns.using) -automation.register_simple_action( +automation.register_apply_action( "binary_sensor.invalidate_state", - BinarySensorInvalidateAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(BinarySensor), }, key=CONF_ID, ), - synchronous=True, + automation.ApplyCall("invalidate_state()"), ) diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 538e2316076..49457200bc6 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -120,14 +120,4 @@ template class MultiClickTrigger final : public MultiClickTriggerBase std::array timing_storage_{}; }; -template class BinarySensorInvalidateAction final : public Action { - public: - explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} - - void play(const Ts &...x) override { this->sensor_->invalidate_state(); } - - protected: - BinarySensor *sensor_; -}; - } // namespace esphome::binary_sensor From 63796e74bb1d5094e3dd17ddd0577d4d46c77c95 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:05:26 +0100 Subject: [PATCH 494/759] Bump aioesphomeapi from 46.5.0 to 46.6.0 (#19648) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f678a9e5e9d..275626b1f07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.5.0 +aioesphomeapi==46.6.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.3 puremagic==2.2.0 From 2157ff5b4b2b0ffd493eaff06d1b34262e4f58b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:09:26 +0100 Subject: [PATCH 495/759] [ds1307] Use register_apply_action for ds1307.write_time and ds1307.read_time (#19638) --- esphome/components/ds1307/ds1307.h | 9 --------- esphome/components/ds1307/time.py | 13 ++++--------- tests/components/ds1307/common-actions.yaml | 7 +++++++ .../components/ds1307/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 tests/components/ds1307/common-actions.yaml create mode 100644 tests/components/ds1307/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 238fc7b21af..ec544ef3a4d 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -55,13 +55,4 @@ class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice } ds1307_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::ds1307 diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 278494c81fc..7a15283f3b7 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -9,8 +9,6 @@ CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] ds1307_ns = cg.esphome_ns.namespace("ds1307") DS1307Component = ds1307_ns.class_("DS1307Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = ds1307_ns.class_("WriteAction", automation.Action) -ReadAction = ds1307_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -20,27 +18,24 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x68)) -automation.register_parented_action( +automation.register_apply_action( "ds1307.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) - -automation.register_parented_action( +automation.register_apply_action( "ds1307.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(DS1307Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) diff --git a/tests/components/ds1307/common-actions.yaml b/tests/components/ds1307/common-actions.yaml new file mode 100644 index 00000000000..b6c03723b54 --- /dev/null +++ b/tests/components/ds1307/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: DS1307 Actions + on_press: + - ds1307.write_time: + id: ds1307_time + - ds1307.read_time: ds1307_time diff --git a/tests/components/ds1307/test-actions.esp8266-ard.yaml b/tests/components/ds1307/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f2326f446c3 --- /dev/null +++ b/tests/components/ds1307/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds1307: !include common.yaml + actions: !include common-actions.yaml From 262ade0598fb69511a6dc5cdf1025a3124f6c79b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:09:37 +0100 Subject: [PATCH 496/759] [senseair] Use register_apply_action for the five senseair actions (#19643) --- esphome/components/senseair/senseair.h | 51 --------------------- esphome/components/senseair/sensor.py | 63 ++++---------------------- 2 files changed, 10 insertions(+), 104 deletions(-) diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 48154a53d95..b376f1f657b 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -37,54 +36,4 @@ class SenseAirComponent final : public PollingComponent, public uart::UARTDevice sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction final : public Action { - public: - SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirBackgroundCalibrationResultAction final : public Action { - public: - SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->background_calibration_result(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCEnableAction final : public Action { - public: - SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_enable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCDisableAction final : public Action { - public: - SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_disable(); } - - protected: - SenseAirComponent *senseair_; -}; - -template class SenseAirABCGetPeriodAction final : public Action { - public: - SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} - - void play(const Ts &...x) override { this->senseair_->abc_get_period(); } - - protected: - SenseAirComponent *senseair_; -}; - } // namespace esphome::senseair diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index ac92efa3a40..9445e5867c3 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -19,21 +19,6 @@ senseair_ns = cg.esphome_ns.namespace("senseair") SenseAirComponent = senseair_ns.class_( "SenseAirComponent", cg.PollingComponent, uart.UARTDevice ) -SenseAirBackgroundCalibrationAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationAction", automation.Action -) -SenseAirBackgroundCalibrationResultAction = senseair_ns.class_( - "SenseAirBackgroundCalibrationResultAction", automation.Action -) -SenseAirABCEnableAction = senseair_ns.class_( - "SenseAirABCEnableAction", automation.Action -) -SenseAirABCDisableAction = senseair_ns.class_( - "SenseAirABCDisableAction", automation.Action -) -SenseAirABCGetPeriodAction = senseair_ns.class_( - "SenseAirABCGetPeriodAction", automation.Action -) CONFIG_SCHEMA = ( cv.Schema( @@ -80,41 +65,13 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "senseair.background_calibration", - SenseAirBackgroundCalibrationAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.background_calibration_result", - SenseAirBackgroundCalibrationResultAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_enable", - SenseAirABCEnableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_disable", - SenseAirABCDisableAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "senseair.abc_get_period", - SenseAirABCGetPeriodAction, - CALIBRATION_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("senseair.background_calibration", "background_calibration()"), + ("senseair.background_calibration_result", "background_calibration_result()"), + ("senseair.abc_enable", "abc_enable()"), + ("senseair.abc_disable", "abc_disable()"), + ("senseair.abc_get_period", "abc_get_period()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) From ad6996a63c570f8b3be01f3ee232f0709cf80478 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:11:05 +0100 Subject: [PATCH 497/759] [key_collector] Use register_apply_action for key_collector.enable and key_collector.disable (#19646) --- esphome/components/key_collector/__init__.py | 32 +++++++------------ .../components/key_collector/key_collector.h | 8 ----- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index c95bdc46b00..dedd960c368 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -35,8 +35,6 @@ CONF_ON_RESULT = "on_result" key_collector_ns = cg.esphome_ns.namespace("key_collector") KeyCollector = key_collector_ns.class_("KeyCollector", cg.Component) -EnableAction = key_collector_ns.class_("EnableAction", automation.Action) -DisableAction = key_collector_ns.class_("DisableAction", automation.Action) X_TYPE = cg.std_string_ref.operator("const") @@ -134,25 +132,19 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_enabled(config[CONF_ENABLE_ON_BOOT])) -automation.register_parented_action( +KEY_COLLECTOR_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(KeyCollector), + } +) + +automation.register_apply_action( "key_collector.enable", - EnableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(true)"), ) - - -automation.register_parented_action( +automation.register_apply_action( "key_collector.disable", - DisableAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(KeyCollector), - } - ), - synchronous=True, + KEY_COLLECTOR_ACTION_SCHEMA, + automation.ApplyCall("set_enabled(false)"), ) diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index c9eeabeb2de..e49cac46108 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -54,12 +54,4 @@ class KeyCollector final : public Component { bool enabled_{}; }; -template class EnableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(true); } -}; - -template class DisableAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_enabled(false); } -}; - } // namespace esphome::key_collector From 32624e248c32c18ecc2e29398e4af051cd03eb3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:11:51 +0100 Subject: [PATCH 498/759] [fingerprint_grow] Use register_apply_action for the six fingerprint_grow actions (#19640) --- .../components/fingerprint_grow/__init__.py | 171 +++++++----------- .../fingerprint_grow/fingerprint_grow.h | 68 ------- .../fingerprint_grow/common-actions.yaml | 16 ++ .../test-actions.esp8266-ard.yaml | 9 + 4 files changed, 87 insertions(+), 177 deletions(-) create mode 100644 tests/components/fingerprint_grow/common-actions.yaml create mode 100644 tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 85fe3994c52..0266a9caad5 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -37,16 +37,6 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( "FingerprintGrowComponent", cg.PollingComponent, uart.UARTDevice ) -EnrollmentAction = fingerprint_grow_ns.class_("EnrollmentAction", automation.Action) -CancelEnrollmentAction = fingerprint_grow_ns.class_( - "CancelEnrollmentAction", automation.Action -) -DeleteAction = fingerprint_grow_ns.class_("DeleteAction", automation.Action) -DeleteAllAction = fingerprint_grow_ns.class_("DeleteAllAction", automation.Action) -LEDControlAction = fingerprint_grow_ns.class_("LEDControlAction", automation.Action) -AuraLEDControlAction = fingerprint_grow_ns.class_( - "AuraLEDControlAction", automation.Action -) AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) AURA_LED_STATES = { @@ -181,103 +171,68 @@ async def to_code(config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -@automation.register_action( - "fingerprint_grow.enroll", - EnrollmentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - cv.Optional(CONF_NUM_SCANS): cv.templatable(cv.uint8_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - if CONF_NUM_SCANS in config: - template_ = await cg.templatable(config[CONF_NUM_SCANS], args, cg.uint8) - cg.add(var.set_num_scans(template_)) - return var - - -automation.register_parented_action( - "fingerprint_grow.cancel_enroll", - CancelEnrollmentAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) - - -@automation.register_action( - "fingerprint_grow.delete", - DeleteAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), - }, - key=CONF_FINGER_ID, - ), - synchronous=True, -) -async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_ = await cg.templatable(config[CONF_FINGER_ID], args, cg.uint16) - cg.add(var.set_finger_id(template_)) - return var - - -automation.register_parented_action( - "fingerprint_grow.delete_all", - DeleteAllAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - } - ), - synchronous=True, -) - - -FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( +FINGERPRINT_GROW_ID_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), - cv.Required(CONF_STATE): cv.templatable(cv.boolean), - }, - key=CONF_STATE, + } ) +automation.register_apply_action( + "fingerprint_grow.enroll", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + cv.Optional(CONF_NUM_SCANS, default=2): cv.templatable( + cv.int_range(min=1, max=255) + ), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyCall( + "enroll_fingerprint({}, {})", + ((CONF_FINGER_ID, cg.uint16), (CONF_NUM_SCANS, cg.uint8)), + ), +) -@automation.register_action( +automation.register_apply_action( + "fingerprint_grow.cancel_enroll", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("finish_enrollment(1)"), +) + +automation.register_apply_action( + "fingerprint_grow.delete", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_FINGER_ID): cv.templatable(cv.uint16_t), + }, + key=CONF_FINGER_ID, + ), + automation.ApplyField(CONF_FINGER_ID, "delete_fingerprint", cg.uint16), +) + +automation.register_apply_action( + "fingerprint_grow.delete_all", + FINGERPRINT_GROW_ID_SCHEMA, + automation.ApplyCall("delete_all_fingerprints()"), +) + +automation.register_apply_action( "fingerprint_grow.led_control", - LEDControlAction, - FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA, - synchronous=True, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FingerprintGrowComponent), + cv.Required(CONF_STATE): cv.templatable(cv.boolean), + }, + key=CONF_STATE, + ), + automation.ApplyField(CONF_STATE, "led_control", cg.bool_), ) -async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var - - -@automation.register_action( +automation.register_apply_action( "fingerprint_grow.aura_led_control", - AuraLEDControlAction, cv.Schema( { cv.GenerateID(): cv.use_id(FingerprintGrowComponent), @@ -287,15 +242,13 @@ async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, cv.Required(CONF_COUNT): cv.templatable(cv.uint8_t), } ), - synchronous=True, + automation.ApplyCall( + "aura_led_control({}, {}, {}, {})", + ( + (CONF_STATE, cg.uint8), + (CONF_SPEED, cg.uint8), + (CONF_COLOR, cg.uint8), + (CONF_COUNT, cg.uint8), + ), + ), ) -async def fingerprint_grow_aura_led_control_to_code( - config, action_id, template_arg, args -): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - for key in [CONF_STATE, CONF_SPEED, CONF_COLOR, CONF_COUNT]: - template_ = await cg.templatable(config[key], args, cg.uint8) - cg.add(getattr(var, f"set_{key}")(template_)) - return var diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 67662192ee4..5256d4489ea 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/uart/uart.h" @@ -209,71 +208,4 @@ class FingerprintGrowComponent final : public PollingComponent, public uart::UAR CallbackManager enrollment_failed_callback_; }; -template -class EnrollmentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - TEMPLATABLE_VALUE(uint8_t, num_scans) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - auto num_scans = this->num_scans_.value(x...); - if (num_scans) { - this->parent_->enroll_fingerprint(finger_id, num_scans); - } else { - this->parent_->enroll_fingerprint(finger_id, 2); - } - } -}; - -template -class CancelEnrollmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } -}; - -template class DeleteAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, finger_id) - - void play(const Ts &...x) override { - auto finger_id = this->finger_id_.value(x...); - this->parent_->delete_fingerprint(finger_id); - } -}; - -template class DeleteAllAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } -}; - -template -class LEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - this->parent_->led_control(state); - } -}; - -template -class AuraLEDControlAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, speed) - TEMPLATABLE_VALUE(uint8_t, color) - TEMPLATABLE_VALUE(uint8_t, count) - - void play(const Ts &...x) override { - auto state = this->state_.value(x...); - auto speed = this->speed_.value(x...); - auto color = this->color_.value(x...); - auto count = this->count_.value(x...); - - this->parent_->aura_led_control(state, speed, color, count); - } -}; - } // namespace esphome::fingerprint_grow diff --git a/tests/components/fingerprint_grow/common-actions.yaml b/tests/components/fingerprint_grow/common-actions.yaml new file mode 100644 index 00000000000..1cecad5875d --- /dev/null +++ b/tests/components/fingerprint_grow/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Fingerprint LED Actions + on_press: + - fingerprint_grow.enroll: 3 + - fingerprint_grow.enroll: + finger_id: !lambda return 4; + num_scans: !lambda return 3; + - fingerprint_grow.led_control: true + - fingerprint_grow.led_control: + state: !lambda return false; + - fingerprint_grow.aura_led_control: + state: BREATHING + speed: 200 + color: BLUE + count: 2 diff --git a/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..a1e3cf67ede --- /dev/null +++ b/tests/components/fingerprint_grow/test-actions.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + sensing_pin: GPIO15 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + fingerprint_grow: !include common.yaml + actions: !include common-actions.yaml From 22035cb9f5e639e4cb90f453451704f37c821b51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:27 +0100 Subject: [PATCH 499/759] [tm1651] Use register_apply_action for the five tm1651 actions (#19647) --- esphome/components/tm1651/__init__.py | 118 ++++++-------------------- esphome/components/tm1651/tm1651.h | 41 --------- 2 files changed, 25 insertions(+), 134 deletions(-) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index b07576e443a..dfe475f7f2c 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,8 +9,6 @@ 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"] @@ -21,11 +19,6 @@ tm1651_ns = cg.esphome_ns.namespace("tm1651") TM1651Brightness = tm1651_ns.enum("TM1651Brightness") TM1651Display = tm1651_ns.class_("TM1651Display", cg.Component) -SetBrightnessAction = tm1651_ns.class_("SetBrightnessAction", automation.Action) -SetLevelAction = tm1651_ns.class_("SetLevelAction", automation.Action) -SetLevelPercentAction = tm1651_ns.class_("SetLevelPercentAction", automation.Action) -TurnOnAction = tm1651_ns.class_("TurnOnAction", automation.Action) -TurnOffAction = tm1651_ns.class_("TurnOffAction", automation.Action) TM1651_BRIGHTNESS_OPTIONS = { 1: TM1651Brightness.TM1651_DARKEST, @@ -66,92 +59,31 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "tm1651.set_brightness", - SetBrightnessAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(validate_brightness), - }, - key=CONF_BRIGHTNESS, +for _name, _key, _validator, _method in ( + ("tm1651.set_brightness", CONF_BRIGHTNESS, validate_brightness, "set_brightness"), + ("tm1651.set_level", CONF_LEVEL, validate_level, "set_level"), + ( + "tm1651.set_level_percent", + CONF_LEVEL_PERCENT, + validate_level_percent, + "set_level_percent", ), - synchronous=True, +): + automation.register_apply_action( + _name, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(TM1651Display), + cv.Required(_key): cv.templatable(_validator), + }, + key=_key, + ), + automation.ApplyField(_key, _method, cg.uint8), + ) + +automation.register_apply_action( + "tm1651.turn_off", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_off()") ) -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) - cg.add(var.set_brightness(template_)) - return var - - -@automation.register_action( - "tm1651.set_level", - SetLevelAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL): cv.templatable(validate_level), - }, - key=CONF_LEVEL, - ), - synchronous=True, -) -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) - cg.add(var.set_level(template_)) - return var - - -@automation.register_action( - "tm1651.set_level_percent", - SetLevelPercentAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(TM1651Display), - cv.Required(CONF_LEVEL_PERCENT): cv.templatable(validate_level_percent), - }, - key=CONF_LEVEL_PERCENT, - ), - synchronous=True, -) -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) - cg.add(var.set_level_percent(template_)) - return var - - -automation.register_parented_action( - "tm1651.turn_off", - TurnOffAction, - BINARY_OUTPUT_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tm1651.turn_on", - TurnOnAction, - BINARY_OUTPUT_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "tm1651.turn_on", BINARY_OUTPUT_ACTION_SCHEMA, automation.ApplyCall("turn_on()") ) diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index 2021f902668..caf526046b8 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -56,44 +55,4 @@ class TM1651Display final : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { - auto brightness = this->brightness_.value(x...); - this->parent_->set_brightness(brightness); - } -}; - -template class SetLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - this->parent_->set_level(level); - } -}; - -template class SetLevelPercentAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level_percent) - - void play(const Ts &...x) override { - auto level_percent = this->level_percent_.value(x...); - this->parent_->set_level_percent(level_percent); - } -}; - -template class TurnOnAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_on(); } -}; - -template class TurnOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->turn_off(); } -}; - } // namespace esphome::tm1651 From 15d756d559b2c8b437855089123eb01fd53bd635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:35 +0100 Subject: [PATCH 500/759] [sps30] Use register_apply_action for the three sps30 actions (#19644) --- esphome/components/sps30/automation.h | 24 ------------- esphome/components/sps30/sensor.py | 35 +++++-------------- tests/components/sps30/common-actions.yaml | 7 ++++ tests/components/sps30/common.yaml | 1 + .../sps30/test-actions.esp8266-ard.yaml | 4 +++ 5 files changed, 20 insertions(+), 51 deletions(-) delete mode 100644 esphome/components/sps30/automation.h create mode 100644 tests/components/sps30/common-actions.yaml create mode 100644 tests/components/sps30/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h deleted file mode 100644 index ba978e7770b..00000000000 --- a/esphome/components/sps30/automation.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" -#include "sps30.h" - -namespace esphome::sps30 { - -template class StartFanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } -}; - -template class StartMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start_measurement(); } -}; - -template class StopMeasurementAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_measurement(); } -}; - -} // namespace esphome::sps30 diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index a310993d82c..0026e38979b 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -37,11 +37,6 @@ SPS30Component = sps30_ns.class_( "SPS30Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -# Actions -StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) -StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) -StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) - CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" CONF_IDLE_INTERVAL = "idle_interval" @@ -180,25 +175,11 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "sps30.start_fan_autoclean", - StartFanAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sps30.start_measurement", - StartMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "sps30.stop_measurement", - StopMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("sps30.start_fan_autoclean", "start_fan_cleaning()"), + ("sps30.start_measurement", "start_measurement()"), + ("sps30.stop_measurement", "stop_measurement()"), +): + automation.register_apply_action( + _name, SPS30_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sps30/common-actions.yaml b/tests/components/sps30/common-actions.yaml new file mode 100644 index 00000000000..00b85ca7671 --- /dev/null +++ b/tests/components/sps30/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: SPS30 Actions + on_press: + - sps30.start_fan_autoclean: workshop_sps30 + - sps30.stop_measurement: workshop_sps30 + - sps30.start_measurement: workshop_sps30 diff --git a/tests/components/sps30/common.yaml b/tests/components/sps30/common.yaml index a83477b764d..47811f7544e 100644 --- a/tests/components/sps30/common.yaml +++ b/tests/components/sps30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: sps30 + id: workshop_sps30 i2c_id: i2c_bus pm_1_0: name: Workshop PM <1µm Weight concentration diff --git a/tests/components/sps30/test-actions.esp8266-ard.yaml b/tests/components/sps30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..1d4f15d8873 --- /dev/null +++ b/tests/components/sps30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sps30: !include common.yaml + actions: !include common-actions.yaml From 857a51994623857107198afd201335a3c2dffcf4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:12:51 +0100 Subject: [PATCH 501/759] [sim800l] Use register_apply_action for the five sim800l actions (#19645) --- esphome/components/sim800l/__init__.py | 71 ++++++-------------------- esphome/components/sim800l/sim800l.h | 63 ----------------------- 2 files changed, 17 insertions(+), 117 deletions(-) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index 7f42edb8631..c472e01f83a 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -11,15 +11,6 @@ MULTI_CONF = True sim800l_ns = cg.esphome_ns.namespace("sim800l") Sim800LComponent = sim800l_ns.class_("Sim800LComponent", cg.Component) -# Actions -Sim800LSendSmsAction = sim800l_ns.class_("Sim800LSendSmsAction", automation.Action) -Sim800LSendUssdAction = sim800l_ns.class_("Sim800LSendUssdAction", automation.Action) -Sim800LDialAction = sim800l_ns.class_("Sim800LDialAction", automation.Action) -Sim800LConnectAction = sim800l_ns.class_("Sim800LConnectAction", automation.Action) -Sim800LDisconnectAction = sim800l_ns.class_( - "Sim800LDisconnectAction", automation.Action -) - CONF_SIM800L_ID = "sim800l_id" CONF_ON_SMS_RECEIVED = "on_sms_received" CONF_ON_USSD_RECEIVED = "on_ussd_received" @@ -90,21 +81,14 @@ SIM800L_SEND_SMS_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "sim800l.send_sms", - Sim800LSendSmsAction, SIM800L_SEND_SMS_SCHEMA, - synchronous=True, + automation.ApplyCall( + "send_sms({}, {})", + ((CONF_RECIPIENT, cg.std_string), (CONF_MESSAGE, cg.std_string)), + ), ) -async def sim800l_send_sms_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - template_ = await cg.templatable(config[CONF_MESSAGE], args, cg.std_string) - cg.add(var.set_message(template_)) - return var - SIM800L_DIAL_SCHEMA = cv.Schema( { @@ -113,25 +97,11 @@ SIM800L_DIAL_SCHEMA = cv.Schema( } ) - -@automation.register_action( - "sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA, synchronous=True +automation.register_apply_action( + "sim800l.dial", + SIM800L_DIAL_SCHEMA, + automation.ApplyField(CONF_RECIPIENT, "dial", cg.std_string), ) -async def sim800l_dial_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_RECIPIENT], args, cg.std_string) - cg.add(var.set_recipient(template_)) - return var - - -automation.register_simple_action( - "sim800l.connect", - Sim800LConnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, -) - SIM800L_SEND_USSD_SCHEMA = cv.Schema( { @@ -140,24 +110,17 @@ SIM800L_SEND_USSD_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "sim800l.send_ussd", - Sim800LSendUssdAction, SIM800L_SEND_USSD_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_USSD, "send_ussd", cg.std_string), ) -async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_USSD], args, cg.std_string) - cg.add(var.set_ussd(template_)) - return var +SIM800L_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}) -automation.register_simple_action( - "sim800l.disconnect", - Sim800LDisconnectAction, - cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), - synchronous=True, +automation.register_apply_action( + "sim800l.connect", SIM800L_ID_SCHEMA, automation.ApplyCall("connect()") +) +automation.register_apply_action( + "sim800l.disconnect", SIM800L_ID_SCHEMA, automation.ApplyCall("disconnect()") ) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 276131cfed8..bbe8e14cf7e 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -120,67 +120,4 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction final : public Action { - public: - Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - TEMPLATABLE_VALUE(std::string, message) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - auto message = this->message_.value(x...); - this->parent_->send_sms(recipient, message); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LSendUssdAction final : public Action { - public: - Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, ussd) - - void play(const Ts &...x) { - auto ussd_code = this->ussd_.value(x...); - this->parent_->send_ussd(ussd_code); - } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDialAction final : public Action { - public: - Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, recipient) - - void play(const Ts &...x) { - auto recipient = this->recipient_.value(x...); - this->parent_->dial(recipient); - } - - protected: - Sim800LComponent *parent_; -}; -template class Sim800LConnectAction final : public Action { - public: - Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->connect(); } - - protected: - Sim800LComponent *parent_; -}; - -template class Sim800LDisconnectAction final : public Action { - public: - Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) { this->parent_->disconnect(); } - - protected: - Sim800LComponent *parent_; -}; - } // namespace esphome::sim800l From 340e105a76b365c20f5d9cf6140b11315337b0c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:28:01 +0100 Subject: [PATCH 502/759] [pzemac] Use register_apply_action for pzemac.reset_energy (#19650) --- esphome/components/pzemac/pzemac.cpp | 2 +- esphome/components/pzemac/pzemac.h | 17 +---------------- esphome/components/pzemac/sensor.py | 8 ++------ 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 409de911249..c0d163c1dec 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -77,7 +77,7 @@ void PZEMAC::dump_config() { LOG_SENSOR("", "Power Factor", this->power_factor_sensor_); } -void PZEMAC::reset_energy_() { +void PZEMAC::reset_energy() { const uint8_t pdu[] = {PZEM_CMD_RESET_ENERGY}; this->queue_pdu(pdu); } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 723b21e0b05..5eb712d4cc8 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -9,8 +8,6 @@ namespace esphome::pzemac { -template class ResetEnergyAction; - class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } @@ -28,27 +25,15 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice modbus::ResponseStatus status) override; void dump_config() override; + void reset_energy(); protected: - template friend class ResetEnergyAction; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; sensor::Sensor *energy_sensor_{nullptr}; sensor::Sensor *frequency_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; - - void reset_energy_(); -}; - -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} - - void play(const Ts &...x) override { this->pzemac_->reset_energy_(); } - - protected: - PZEMAC *pzemac_; }; } // namespace esphome::pzemac diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 1670204468c..ff4cedc8146 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -33,9 +33,6 @@ AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -83,15 +80,14 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "pzemac.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PZEMAC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 9ae83f28301decb9dcad1a49472d8cfb679aa981 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:30:30 +0100 Subject: [PATCH 503/759] [hbridge] Use register_apply_action for fan.hbridge.brake (#19649) --- esphome/components/hbridge/fan/__init__.py | 8 ++------ esphome/components/hbridge/fan/hbridge_fan.h | 10 ---------- tests/components/hbridge/common-actions.yaml | 5 +++++ tests/components/hbridge/test-actions.esp8266-ard.yaml | 10 ++++++++++ 4 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 tests/components/hbridge/common-actions.yaml create mode 100644 tests/components/hbridge/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index cd47d100be4..e862760e6ae 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -27,9 +27,6 @@ DECAY_MODE_OPTIONS = { "FAST": DecayMode.DECAY_MODE_FAST, } -# Actions -BrakeAction = hbridge_ns.class_("BrakeAction", automation.Action) - CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) .extend( @@ -48,11 +45,10 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "fan.hbridge.brake", - BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), - synchronous=True, + automation.ApplyCall("brake()"), ) diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 187b6d2a97f..c786f606740 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" #include "esphome/components/fan/fan.h" @@ -46,13 +45,4 @@ class HBridgeFan final : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction final : public Action { - public: - explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->brake(); } - - HBridgeFan *parent_; -}; - } // namespace esphome::hbridge diff --git a/tests/components/hbridge/common-actions.yaml b/tests/components/hbridge/common-actions.yaml new file mode 100644 index 00000000000..e0638c49c04 --- /dev/null +++ b/tests/components/hbridge/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: Brake the fan + on_press: + - fan.hbridge.brake: fan_hbridge diff --git a/tests/components/hbridge/test-actions.esp8266-ard.yaml b/tests/components/hbridge/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..752ab72bf9a --- /dev/null +++ b/tests/components/hbridge/test-actions.esp8266-ard.yaml @@ -0,0 +1,10 @@ +substitutions: + pwm_platform: "esp8266_pwm" + output1_pin: "4" + output2_pin: "5" + output3_pin: "12" + output4_pin: "13" + +packages: + hbridge: !include common.yaml + actions: !include common-actions.yaml From 9c2616ef7efbd4ee19c909619d7a64b7e2dfa2ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:31:11 +0100 Subject: [PATCH 504/759] [pzemdc] Use register_apply_action for pzemdc.reset_energy (#19651) --- esphome/components/pzemdc/pzemdc.h | 11 ----------- esphome/components/pzemdc/sensor.py | 8 ++------ 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 69c8a9dd6c2..954fbae9783 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" @@ -34,14 +33,4 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction final : public Action { - public: - ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} - - void play(const Ts &...x) override { this->pzemdc_->reset_energy(); } - - protected: - PZEMDC *pzemdc_; -}; - } // namespace esphome::pzemdc diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 97e9d09d058..8ec88d69095 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -27,9 +27,6 @@ AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) -# Actions -ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -65,15 +62,14 @@ CONFIG_SCHEMA = ( ) -automation.register_simple_action( +automation.register_apply_action( "pzemdc.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.GenerateID(CONF_ID): cv.use_id(PZEMDC), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 6a115e6b252ab11cfdf72ab317948fd9696630b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:31:39 +0100 Subject: [PATCH 505/759] [sen5x] Use register_apply_action for sen5x.start_fan_autoclean (#19653) --- esphome/components/sen5x/automation.h | 19 ------------------- esphome/components/sen5x/sensor.py | 8 ++------ tests/components/sen5x/common-actions.yaml | 5 +++++ .../sen5x/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 11 insertions(+), 25 deletions(-) delete mode 100644 esphome/components/sen5x/automation.h create mode 100644 tests/components/sen5x/common-actions.yaml create mode 100644 tests/components/sen5x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h deleted file mode 100644 index 21d938c4fea..00000000000 --- a/esphome/components/sen5x/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "sen5x.h" - -namespace esphome::sen5x { - -template class StartFanAction final : public Action { - public: - explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} - - void play(const Ts &...x) override { this->sen5x_->start_fan_cleaning(); } - - protected: - SEN5XComponent *sen5x_; -}; - -} // namespace esphome::sen5x diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index e7f61d8b2de..7bfce0f26a5 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -60,9 +60,6 @@ CONF_ACCELERATION_MODE = "acceleration_mode" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" -# Actions -StartFanAction = sen5x_ns.class_("StartFanAction", automation.Action) - ACCELERATION_MODES = { "low": RhtAccelerationMode.LOW_ACCELERATION, "medium": RhtAccelerationMode.MEDIUM_ACCELERATION, @@ -280,9 +277,8 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( +automation.register_apply_action( "sen5x.start_fan_autoclean", - StartFanAction, SEN5X_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start_fan_cleaning()"), ) diff --git a/tests/components/sen5x/common-actions.yaml b/tests/components/sen5x/common-actions.yaml new file mode 100644 index 00000000000..a6294878016 --- /dev/null +++ b/tests/components/sen5x/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: SEN5x Clean the fan + on_press: + - sen5x.start_fan_autoclean: sen54 diff --git a/tests/components/sen5x/test-actions.esp8266-ard.yaml b/tests/components/sen5x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..6b6ff010670 --- /dev/null +++ b/tests/components/sen5x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + sen5x: !include common.yaml + actions: !include common-actions.yaml From 11c8e8c0c097085259d786a460250b537a644ad7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:32:05 +0100 Subject: [PATCH 506/759] [scd30] Use register_apply_action for scd30.force_recalibration_with_reference (#19652) --- esphome/components/scd30/automation.h | 22 ------------------ esphome/components/scd30/sensor.py | 23 ++----------------- tests/components/scd30/common-actions.yaml | 8 +++++++ tests/components/scd30/common.yaml | 1 + .../scd30/test-actions.esp8266-ard.yaml | 4 ++++ 5 files changed, 15 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/scd30/automation.h create mode 100644 tests/components/scd30/common-actions.yaml create mode 100644 tests/components/scd30/test-actions.esp8266-ard.yaml diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h deleted file mode 100644 index a816ae1f260..00000000000 --- a/esphome/components/scd30/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd30.h" - -namespace esphome::scd30 { - -template -class ForceRecalibrationWithReference final : public Action, public Parented { - public: - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->force_recalibration_with_reference(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -} // namespace esphome::scd30 diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 37789100f72..aadc85ddb7e 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -22,8 +22,6 @@ 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"] @@ -34,11 +32,6 @@ SCD30Component = scd30_ns.class_( "SCD30Component", cg.Component, sensirion_common.SensirionI2CDevice ) -# Actions -ForceRecalibrationWithReference = scd30_ns.class_( - "ForceRecalibrationWithReference", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -119,9 +112,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_sensor(sens)) -@automation.register_action( +automation.register_apply_action( "scd30.force_recalibration_with_reference", - ForceRecalibrationWithReference, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SCD30Component), @@ -131,16 +123,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_VALUE, ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "force_recalibration_with_reference", cg.uint16), ) -async def scd30_force_recalibration_with_reference_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) - cg.add(var.set_value(template_)) - return var diff --git a/tests/components/scd30/common-actions.yaml b/tests/components/scd30/common-actions.yaml new file mode 100644 index 00000000000..ed88e566da3 --- /dev/null +++ b/tests/components/scd30/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: SCD30 Calibrate + on_press: + - scd30.force_recalibration_with_reference: 419 + - scd30.force_recalibration_with_reference: + id: co2_scd30 + value: !lambda return 420; diff --git a/tests/components/scd30/common.yaml b/tests/components/scd30/common.yaml index f21d8944dce..4d15e83c653 100644 --- a/tests/components/scd30/common.yaml +++ b/tests/components/scd30/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: scd30 + id: co2_scd30 i2c_id: i2c_bus co2: name: SCD30 CO2 diff --git a/tests/components/scd30/test-actions.esp8266-ard.yaml b/tests/components/scd30/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eccbb90cc76 --- /dev/null +++ b/tests/components/scd30/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + scd30: !include common.yaml + actions: !include common-actions.yaml From 432cae58aee8c08a83443fa62f93d91832f9305c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:51:14 +0100 Subject: [PATCH 507/759] [nau7802] Use register_apply_action for the three calibration actions (#19657) --- esphome/components/nau7802/nau7802.h | 18 ------------ esphome/components/nau7802/sensor.py | 43 ++++++---------------------- 2 files changed, 8 insertions(+), 53 deletions(-) diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index c53a018234d..e31070e0a39 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -96,21 +95,4 @@ class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, publ void set_calibration_failure_(bool failed); }; -template -class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } -}; - -template -class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } -}; - -template class NAU7802CalbrateGainAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->calibrate_gain(); } -}; - } // namespace esphome::nau7802 diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 3112df99d03..2647d40441a 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -18,19 +18,6 @@ nau7802_ns = cg.esphome_ns.namespace("nau7802") NAU7802Sensor = nau7802_ns.class_( "NAU7802Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) -NAU7802CalbrateExternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateExternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateInternalOffsetAction = nau7802_ns.class_( - "NAU7802CalbrateInternalOffsetAction", - automation.Action, - cg.Parented.template(NAU7802Sensor), -) -NAU7802CalbrateGainAction = nau7802_ns.class_( - "NAU7802CalbrateGainAction", automation.Action, cg.Parented.template(NAU7802Sensor) -) NAU7802Gain = nau7802_ns.enum("NAU7802Gain") GAINS = { @@ -114,25 +101,11 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( ) -automation.register_parented_action( - "nau7802.calibrate_internal_offset", - NAU7802CalbrateInternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "nau7802.calibrate_external_offset", - NAU7802CalbrateExternalOffsetAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "nau7802.calibrate_gain", - NAU7802CalbrateGainAction, - NAU7802_CALIBRATE_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("nau7802.calibrate_internal_offset", "calibrate_internal_offset()"), + ("nau7802.calibrate_external_offset", "calibrate_external_offset()"), + ("nau7802.calibrate_gain", "calibrate_gain()"), +): + automation.register_apply_action( + _name, NAU7802_CALIBRATE_SCHEMA, automation.ApplyCall(_call) + ) From c16f75f0e2c839dbe98efcaf0411d3a60d7364d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:51:41 +0100 Subject: [PATCH 508/759] [pcf85063] Use register_apply_action for pcf85063.write_time and pcf85063.read_time (#19659) --- esphome/components/pcf85063/pcf85063.h | 9 --------- esphome/components/pcf85063/time.py | 13 ++++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 659260ba5e5..717803490a5 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -81,13 +81,4 @@ class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevic } pcf85063_; }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; } // namespace esphome::pcf85063 diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index cc7a373b42d..3f993c17e19 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -11,8 +11,6 @@ pcf85063_ns = cg.esphome_ns.namespace("pcf85063") PCF85063Component = pcf85063_ns.class_( "PCF85063Component", time.RealTimeClock, i2c.I2CDevice ) -WriteAction = pcf85063_ns.class_("WriteAction", automation.Action) -ReadAction = pcf85063_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -22,27 +20,24 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x51)) -automation.register_parented_action( +automation.register_apply_action( "pcf85063.write_time", - WriteAction, cv.Schema( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("write_time()"), ) - -automation.register_parented_action( +automation.register_apply_action( "pcf85063.read_time", - ReadAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(PCF85063Component), } ), - synchronous=True, + automation.ApplyCall("read_time()"), ) From 288a7b376a56b8aa620c4bdd66b91acfd80d73ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:52:16 +0100 Subject: [PATCH 509/759] [htu21d] Use register_apply_action for htu21d.set_heater and htu21d.set_heater_level (#19658) --- esphome/components/htu21d/htu21d.h | 23 ------------------ esphome/components/htu21d/sensor.py | 37 ++++------------------------- 2 files changed, 4 insertions(+), 56 deletions(-) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index f86d62c5e8d..c4babfd797d 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -3,7 +3,6 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" namespace esphome::htu21d { @@ -34,26 +33,4 @@ class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, level) - - void play(const Ts &...x) override { - auto level = this->level_.value(x...); - - this->parent_->set_heater_level(level); - } -}; - -template class SetHeaterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, status) - - void play(const Ts &...x) override { - auto status = this->status_.value(x...); - - this->parent_->set_heater(status); - } -}; - } // namespace esphome::htu21d diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 86dca777255..08888c17e5c 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,8 +17,6 @@ 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"] @@ -27,8 +25,6 @@ htu21d_ns = cg.esphome_ns.namespace("htu21d") HTU21DComponent = htu21d_ns.class_( "HTU21DComponent", cg.PollingComponent, i2c.I2CDevice ) -SetHeaterLevelAction = htu21d_ns.class_("SetHeaterLevelAction", automation.Action) -SetHeaterAction = htu21d_ns.class_("SetHeaterAction", automation.Action) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") MODELS = { @@ -86,9 +82,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sensor_model(config[CONF_MODEL])) -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater_level", - SetHeaterLevelAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -96,24 +91,11 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_LEVEL, ), - synchronous=True, + automation.ApplyField(CONF_LEVEL, "set_heater_level", cg.uint8), ) -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) - cg.add(var.set_level(level_)) - return var - -@automation.register_action( +automation.register_apply_action( "htu21d.set_heater", - SetHeaterAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HTU21DComponent), @@ -121,16 +103,5 @@ async def set_heater_level_to_code( }, key=CONF_STATUS, ), - synchronous=True, + automation.ApplyField(CONF_STATUS, "set_heater", cg.bool_), ) -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_) - cg.add(var.set_status(status_)) - return var From 1edc3278354bd2bbcc447b79fe660aac1adb7e8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:56:06 +0100 Subject: [PATCH 510/759] [cc1101] Use register_apply_action for begin_tx, begin_rx, reset and set_idle (#19661) --- esphome/components/cc1101/__init__.py | 43 ++++++--------------------- esphome/components/cc1101/cc1101.h | 20 ------------- 2 files changed, 9 insertions(+), 54 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index d851d7386df..5de39a16cec 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -351,10 +351,6 @@ async def to_code(config): # Actions -BeginTxAction = ns.class_("BeginTxAction", automation.Action) -BeginRxAction = ns.class_("BeginRxAction", automation.Action) -ResetAction = ns.class_("ResetAction", automation.Action) -SetIdleAction = ns.class_("SetIdleAction", automation.Action) SendPacketAction = ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) ) @@ -364,36 +360,15 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -automation.register_parented_action( - "cc1101.begin_tx", - BeginTxAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.begin_rx", - BeginRxAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.reset", - ResetAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "cc1101.set_idle", - SetIdleAction, - CC1101_ACTION_SCHEMA, - synchronous=True, -) +for _name, _call in ( + ("cc1101.begin_tx", "begin_tx()"), + ("cc1101.begin_rx", "begin_rx()"), + ("cc1101.reset", "reset()"), + ("cc1101.set_idle", "set_idle()"), +): + automation.register_apply_action( + _name, CC1101_ACTION_SCHEMA, automation.ApplyCall(_call) + ) def validate_raw_data(value): diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 1f7a55fbf14..499f77a809c 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -130,26 +130,6 @@ class CC1101Component final : public Component, }; // Action Wrappers -template class BeginTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_tx(); } -}; - -template class BeginRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_rx(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetIdleAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_idle(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } From 14bd50d46912a277eb407afc615e45d48de3dfd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 19:56:32 +0100 Subject: [PATCH 511/759] [sound_level] Use register_apply_action for sound_level.start and sound_level.stop (#19656) --- esphome/components/sound_level/sensor.py | 18 ++++-------------- esphome/components/sound_level/sound_level.h | 11 ----------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index fe00ae41ba7..a28e91dfd52 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -25,8 +25,6 @@ CONF_RMS = "rms" sound_level_ns = cg.esphome_ns.namespace("sound_level") SoundLevelComponent = sound_level_ns.class_("SoundLevelComponent", cg.Component) -StartAction = sound_level_ns.class_("StartAction", automation.Action) -StopAction = sound_level_ns.class_("StopAction", automation.Action) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -90,17 +88,9 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_parented_action( - "sound_level.start", - StartAction, - SOUND_LEVEL_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "sound_level.start", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("start()") ) - - -automation.register_parented_action( - "sound_level.stop", - StopAction, - SOUND_LEVEL_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "sound_level.stop", SOUND_LEVEL_ACTION_SCHEMA, automation.ApplyCall("stop()") ) diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index 94c18421baf..008b8d62262 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -7,7 +7,6 @@ #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::sound_level { @@ -59,16 +58,6 @@ class SoundLevelComponent final : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - } // namespace esphome::sound_level #endif From a1b0e85a9e024dbf1134bab914fd7c3cbaa533e8 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 25 Sep 2026 12:18:32 -0700 Subject: [PATCH 512/759] [core] Report unknown for switch, climate and water heater with no state (#19458) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 9 ++ esphome/components/api/api_connection.cpp | 3 + esphome/components/api/api_pb2.cpp | 6 + esphome/components/api/api_pb2.h | 9 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/climate/climate.cpp | 1 + esphome/components/switch/switch.cpp | 1 + .../components/template/climate/__init__.py | 1 + .../water_heater/template_water_heater.cpp | 6 +- .../components/water_heater/water_heater.cpp | 1 + .../fixtures/entity_missing_state.yaml | 78 +++++++++++ .../integration/test_entity_missing_state.py | 132 ++++++++++++++++++ 12 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/integration/fixtures/entity_missing_state.yaml create mode 100644 tests/integration/test_entity_missing_state.py diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 21972decad1..687dc1ca951 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -803,6 +803,9 @@ message SwitchStateResponse { fixed32 key = 1 [(force) = true]; bool state = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; + // If the switch does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 4; } message SwitchCommandRequest { option (id) = 33; @@ -1244,6 +1247,9 @@ message ClimateStateResponse { float current_humidity = 14; float target_humidity = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; + // If the climate device does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 17; } message ClimateCommandRequest { option (id) = 48; @@ -1330,6 +1336,9 @@ message WaterHeaterStateResponse { uint32 state = 6; float target_temperature_low = 7; float target_temperature_high = 8; + // If the water heater does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 9; } // Bitmask for WaterHeaterCommandRequest.has_fields diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3d685e22d98..0207bc14b9f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -709,6 +709,7 @@ uint16_t APIConnection::try_send_switch_state(EntityBase *entity, APIConnection auto *a_switch = static_cast(entity); SwitchStateResponse resp; resp.state = a_switch->state; + resp.missing_state = !a_switch->has_state(); return fill_and_encode_entity_state(a_switch, resp, conn, remaining_size); } @@ -754,6 +755,7 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection auto traits = climate->get_traits(); resp.mode = static_cast(climate->mode); resp.action = static_cast(climate->action); + resp.missing_state = !climate->has_state(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) resp.current_temperature = climate->current_temperature; if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | @@ -1447,6 +1449,7 @@ uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConne auto *wh = static_cast(entity); WaterHeaterStateResponse resp; resp.mode = static_cast(wh->get_mode()); + resp.missing_state = !wh->has_state(); resp.current_temperature = wh->get_current_temperature(); resp.target_temperature = wh->get_target_temperature(); resp.target_temperature_low = wh->get_target_temperature_low(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 9e4000b55bc..1d1f594aa73 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -938,6 +938,7 @@ uint8_t *SwitchStateResponse::encode_msg(const void *self, ProtoWriteBuffer &buf #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.missing_state); return pos; } uint32_t SwitchStateResponse::calc_size_msg(const void *self) { @@ -948,6 +949,7 @@ uint32_t SwitchStateResponse::calc_size_msg(const void *self) { #ifdef USE_DEVICES size += ProtoSize::calc_uint32(1, msg.device_id); #endif + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } void SwitchCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1630,6 +1632,7 @@ uint8_t *ClimateStateResponse::encode_msg(const void *self, ProtoWriteBuffer &bu #ifdef USE_DEVICES pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.missing_state); return pos; } uint32_t ClimateStateResponse::calc_size_msg(const void *self) { @@ -1652,6 +1655,7 @@ uint32_t ClimateStateResponse::calc_size_msg(const void *self) { #ifdef USE_DEVICES size += ProtoSize::calc_uint32(2, msg.device_id); #endif + size += ProtoSize::calc_bool(2, msg.missing_state); return size; } void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { @@ -1806,6 +1810,7 @@ uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, ProtoWriteBuffer if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.missing_state); return pos; } uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { @@ -1821,6 +1826,7 @@ uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.state); size += ProtoSize::calc_float(1, msg.target_temperature_low); size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5b3683a1ab9..dbf0fb49a1e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1109,11 +1109,12 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { class SwitchStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 26; - static constexpr uint8_t ESTIMATED_SIZE = 11; + static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_state_response"); } #endif bool state{false}; + bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1690,7 +1691,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 47; - static constexpr uint8_t ESTIMATED_SIZE = 68; + static constexpr uint8_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } #endif @@ -1707,6 +1708,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { StringRef custom_preset{nullptr, 0}; // null until set, encode only float current_humidity{0.0f}; float target_humidity{0.0f}; + bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); @@ -1786,7 +1788,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 133; - static constexpr uint8_t ESTIMATED_SIZE = 35; + static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } #endif @@ -1796,6 +1798,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; + bool missing_state{false}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bb244973a11..d854a0731be 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1342,6 +1342,7 @@ const char *SwitchStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *SwitchCommandRequest::dump_to(DumpBuffer &out) const { @@ -1684,6 +1685,7 @@ const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *ClimateCommandRequest::dump_to(DumpBuffer &out) const { @@ -1751,6 +1753,7 @@ const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("state"), this->state); dump_field(out, ESPHOME_PSTR("target_temperature_low"), this->target_temperature_low); dump_field(out, ESPHOME_PSTR("target_temperature_high"), this->target_temperature_high); + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *WaterHeaterCommandRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index f80de151b11..494efd8edc4 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -435,6 +435,7 @@ void Climate::save_state_(const ClimateTraits &traits) { } void Climate::publish_state() { + this->set_has_state(true); ESP_LOGV(TAG, "'%s' >>", this->name_.c_str()); auto traits = this->get_traits(); diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 57e4f222bce..2cc8ad782d9 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -57,6 +57,7 @@ void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; this->state = state != this->inverted_; + this->set_has_state(true); if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 11ffaf282c3..5f7627c5c38 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -374,6 +374,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_preset(v)) if (v := initial_state.get(CONF_CUSTOM_PRESET)) is not None: cg.add(var.set_custom_preset(v)) + cg.add(var.set_has_state(True)) CLIMATE_TEMPLATE_PUBLISH_ACTION_SCHEMA = cv.All( diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 9d6a3523d28..7718688d68a 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -91,7 +91,11 @@ void TemplateWaterHeater::loop() { } } - if (changed) { + // The first value always publishes, even one equal to the default (mode OFF, say), or the + // entity would report unknown forever. + const bool has_value = + curr_temp.has_value() || target_temp.has_value() || new_mode.has_value() || away.has_value() || is_on.has_value(); + if (changed || (has_value && !this->has_state())) { this->publish_state(); } } diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 1dc2d008a13..4b65e907de7 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -161,6 +161,7 @@ void WaterHeaterCall::validate_() { } void WaterHeater::publish_state() { + this->set_has_state(true); auto traits = this->get_traits(); ESP_LOGV(TAG, "'%s' >>\n" diff --git a/tests/integration/fixtures/entity_missing_state.yaml b/tests/integration/fixtures/entity_missing_state.yaml new file mode 100644 index 00000000000..6297716c6be --- /dev/null +++ b/tests/integration/fixtures/entity_missing_state.yaml @@ -0,0 +1,78 @@ +esphome: + name: test-missing-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + +# None of these publish a state at startup, so each one starts out unknown +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true + restore_mode: DISABLED + +climate: + - platform: template + name: "Test Climate" + id: test_climate + optimistic: true + supported_modes: + - "OFF" + - HEAT + + # A configured initial state is a state, so this one starts out known + - platform: template + name: "Initial Climate" + id: initial_climate + optimistic: true + restore_mode: NO_RESTORE + supported_modes: + - "OFF" + - HEAT + initial_state: + mode: HEAT + +globals: + - id: lambda_mode_known + type: bool + initial_value: "false" + +water_heater: + - platform: template + name: "Test Water Heater" + id: test_water_heater + supported_modes: + - "OFF" + - ECO + + # Reports OFF, which is also the default, once the MCU-equivalent global says so + - platform: template + name: "Lambda Water Heater" + id: lambda_water_heater + supported_modes: + - "OFF" + - ECO + mode: |- + if (!id(lambda_mode_known)) { + return {}; + } + return water_heater::WATER_HEATER_MODE_OFF; + +button: + - platform: template + name: "Publish States" + id: publish_states_button + on_press: + - switch.turn_on: test_switch + - climate.template.publish: + id: test_climate + mode: HEAT + - water_heater.template.publish: + id: test_water_heater + mode: ECO + - globals.set: + id: lambda_mode_known + value: "true" diff --git a/tests/integration/test_entity_missing_state.py b/tests/integration/test_entity_missing_state.py new file mode 100644 index 00000000000..f87ee213462 --- /dev/null +++ b/tests/integration/test_entity_missing_state.py @@ -0,0 +1,132 @@ +"""Integration test for missing_state on switch, climate and water heater. + +These three entity types always sent a concrete state, so a client could not +tell a real OFF from an entity whose value has not been read yet. They now +report missing_state until something publishes, like every other stateful +entity does. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ClimateInfo, + ClimateMode, + ClimateState, + EntityInfo, + EntityState, + SwitchInfo, + SwitchState, + WaterHeaterInfo, + WaterHeaterMode, + WaterHeaterState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_entity_missing_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a switch, climate and water heater start out unknown. + + This verifies that: + 1. They report missing_state=True while nothing has published + 2. Publishing clears missing_state and reports the published value + 3. A first value that happens to equal the default still publishes + """ + loop = asyncio.get_running_loop() + futures: dict[int, asyncio.Future[EntityState]] = {} + + def on_state(state: EntityState) -> None: + """Resolve the pending future for the entity that changed.""" + future = futures.get(state.key) + if future is not None and not future.done(): + future.set_result(state) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + switch_info = require_entity(entities, "test_switch", SwitchInfo) + climate_info = require_entity(entities, "test_climate", ClimateInfo) + initial_climate_info = require_entity(entities, "initial_climate", ClimateInfo) + water_heater_info = require_entity( + entities, "test_water_heater", WaterHeaterInfo + ) + lambda_water_heater_info = require_entity( + entities, "lambda_water_heater", WaterHeaterInfo + ) + publish_button = require_entity( + entities, "publish_states", description="Publish States button" + ) + stateful: list[EntityInfo] = [ + switch_info, + climate_info, + water_heater_info, + lambda_water_heater_info, + ] + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Nothing has published yet, so all of them must report unknown + for info in stateful: + state = initial_state_helper.initial_states.get(info.key) + assert state is not None, f"No initial state received for {info.object_id}" + assert state.missing_state is True, ( + f"Initial state for {info.object_id} should have " + f"missing_state=True, got {state}" + ) + + # A configured initial_state must not start out unknown + initial_climate_state = initial_state_helper.initial_states.get( + initial_climate_info.key + ) + assert isinstance(initial_climate_state, ClimateState) + assert initial_climate_state.missing_state is False + assert initial_climate_state.mode is ClimateMode.HEAT + + # Publishing a state on each one clears missing_state + futures = {info.key: loop.create_future() for info in stateful} + client.button_command(publish_button.key) + + try: + await asyncio.wait_for(asyncio.gather(*futures.values()), timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for published states") + + switch_state = futures[switch_info.key].result() + assert isinstance(switch_state, SwitchState) + assert switch_state.missing_state is False + assert switch_state.state is True + + climate_state = futures[climate_info.key].result() + assert isinstance(climate_state, ClimateState) + assert climate_state.missing_state is False + assert climate_state.mode is ClimateMode.HEAT + + water_heater_state = futures[water_heater_info.key].result() + assert isinstance(water_heater_state, WaterHeaterState) + assert water_heater_state.missing_state is False + assert water_heater_state.mode is WaterHeaterMode.ECO + + # Its first value is OFF, which is also the default the entity starts on, + # so it only leaves unknown if the first value publishes regardless + lambda_state = futures[lambda_water_heater_info.key].result() + assert isinstance(lambda_state, WaterHeaterState) + assert lambda_state.missing_state is False + assert lambda_state.mode is WaterHeaterMode.OFF From 456e13adee56822a85096ede7d343dee62e3f92b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:44 +0100 Subject: [PATCH 513/759] [ld2410] Use register_apply_action for bluetooth_password.set (#19667) --- esphome/components/ld2410/__init__.py | 24 ++----------------- esphome/components/ld2410/automation.h | 20 ---------------- esphome/components/ld2410/ld2410.h | 1 - tests/components/ld2410/common-actions.yaml | 10 ++++++++ .../ld2410/test-actions.esp8266-ard.yaml | 8 +++++++ 5 files changed, 20 insertions(+), 43 deletions(-) delete mode 100644 esphome/components/ld2410/automation.h create mode 100644 tests/components/ld2410/common-actions.yaml create mode 100644 tests/components/ld2410/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 19786f38d32..f157cb50fd1 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,8 +4,6 @@ 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"] @@ -86,11 +84,6 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( # Actions -BluetoothPasswordSetAction = ld2410_ns.class_( - "BluetoothPasswordSetAction", automation.Action -) - - BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(LD2410Component), @@ -98,21 +91,8 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "bluetooth_password.set", - BluetoothPasswordSetAction, BLUETOOTH_PASSWORD_SET_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_PASSWORD, "set_bluetooth_password", cg.std_string), ) -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) - cg.add(var.set_password(template_)) - return var diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h deleted file mode 100644 index b0b9591d378..00000000000 --- a/esphome/components/ld2410/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "ld2410.h" - -namespace esphome::ld2410 { - -template class BluetoothPasswordSetAction final : public Action { - public: - explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} - TEMPLATABLE_VALUE(std::string, password) - - void play(const Ts &...x) override { this->ld2410_comp_->set_bluetooth_password(this->password_.value(x...)); } - - protected: - LD2410Component *ld2410_comp_; -}; - -} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 061846f1f19..6c7601df3d2 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -24,7 +24,6 @@ #endif #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include diff --git a/tests/components/ld2410/common-actions.yaml b/tests/components/ld2410/common-actions.yaml new file mode 100644 index 00000000000..7cd4d6abbc0 --- /dev/null +++ b/tests/components/ld2410/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: LD2410 Actions + on_press: + - bluetooth_password.set: + id: my_ld2410 + password: HiLink + - bluetooth_password.set: + id: my_ld2410 + password: !lambda return "HiLink"; diff --git a/tests/components/ld2410/test-actions.esp8266-ard.yaml b/tests/components/ld2410/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..188d8cb87fc --- /dev/null +++ b/tests/components/ld2410/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2410: !include common.yaml + actions: !include common-actions.yaml From 29512cf323ae3da4074566899bbeb4d242f10002 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:46 +0100 Subject: [PATCH 514/759] [pmwcs3] Use register_apply_action for the calibration and address actions (#19666) --- esphome/components/pmwcs3/pmwcs3.h | 32 -------------- esphome/components/pmwcs3/sensor.py | 43 +++---------------- tests/components/pmwcs3/common-actions.yaml | 9 ++++ .../pmwcs3/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 19 insertions(+), 69 deletions(-) create mode 100644 tests/components/pmwcs3/common-actions.yaml create mode 100644 tests/components/pmwcs3/test-actions.esp8266-ard.yaml diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index 4ce4a5ce9c7..8ca995fbe7d 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -1,5 +1,4 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -32,35 +31,4 @@ class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction final : public Action { - public: - PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->air_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3WaterCalibrationAction final : public Action { - public: - PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->water_calibration(); } - - protected: - PMWCS3Component *parent_; -}; - -template class PMWCS3NewI2cAddressAction final : public Action { - public: - PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(int, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } - - protected: - PMWCS3Component *parent_; -}; - } // namespace esphome::pmwcs3 diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 552cfafa04d..19d0083fd04 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -10,8 +10,6 @@ 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"] @@ -29,17 +27,6 @@ PMWCS3Component = pmwcs3_ns.class_( "PMWCS3Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -PMWCS3AirCalibrationAction = pmwcs3_ns.class_( - "PMWCS3AirCalibrationAction", automation.Action -) -PMWCS3WaterCalibrationAction = pmwcs3_ns.class_( - "PMWCS3WaterCalibrationAction", automation.Action -) -PMWCS3NewI2cAddressAction = pmwcs3_ns.class_( - "PMWCS3NewI2cAddressAction", automation.Action -) - CONFIG_SCHEMA = ( cv.Schema( { @@ -104,23 +91,18 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( } ) - -automation.register_simple_action( +automation.register_apply_action( "pmwcs3.air_calibration", - PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, + automation.ApplyCall("air_calibration()"), ) - -automation.register_simple_action( +automation.register_apply_action( "pmwcs3.water_calibration", - PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, - synchronous=True, + automation.ApplyCall("water_calibration()"), ) - PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(PMWCS3Component), @@ -129,21 +111,8 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( key=CONF_ADDRESS, ) - -@automation.register_action( +automation.register_apply_action( "pmwcs3.new_i2c_address", - PMWCS3NewI2cAddressAction, PMWCS3_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -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_) - cg.add(var.set_new_address(address)) - return var diff --git a/tests/components/pmwcs3/common-actions.yaml b/tests/components/pmwcs3/common-actions.yaml new file mode 100644 index 00000000000..c87f4c8c5b1 --- /dev/null +++ b/tests/components/pmwcs3/common-actions.yaml @@ -0,0 +1,9 @@ +button: + - platform: template + name: PMWCS3 Actions + on_press: + - pmwcs3.air_calibration: + - pmwcs3.water_calibration: + - pmwcs3.new_i2c_address: 0x64 + - pmwcs3.new_i2c_address: + address: !lambda return 0x65; diff --git a/tests/components/pmwcs3/test-actions.esp8266-ard.yaml b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..33c1f8a83a3 --- /dev/null +++ b/tests/components/pmwcs3/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pmwcs3: !include common.yaml + actions: !include common-actions.yaml From cf9d807bf05bf777405e5d200d83faea2aa07bf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:20:57 +0100 Subject: [PATCH 515/759] [core] Make Action::stop_complex and Action::is_running non-virtual (#19662) --- esphome/core/automation.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index b6058925b4a..61f34d70f45 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -486,7 +486,7 @@ template class Action { this->play(x...); this->play_next_(x...); } - virtual void stop_complex() { + void stop_complex() { if (num_running_) { this->stop(); this->num_running_ = 0; @@ -494,7 +494,7 @@ template class Action { this->stop_next_(); } /// Check if this or any of the following actions are currently running. - virtual bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } + bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } /// The total number of actions that are currently running in this plus any of /// the following actions in the chain. From dfd9d2639f7f5fe3f37517c485df013f26f2cf3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:21:23 +0100 Subject: [PATCH 516/759] [ezo_pmp] Use register_apply_action for the twelve ezo_pmp actions (#19663) --- esphome/components/ezo_pmp/__init__.py | 193 +++--------------- esphome/components/ezo_pmp/ezo_pmp.h | 134 ------------ tests/components/ezo_pmp/common-actions.yaml | 20 ++ .../ezo_pmp/test-actions.esp8266-ard.yaml | 4 + 4 files changed, 53 insertions(+), 298 deletions(-) create mode 100644 tests/components/ezo_pmp/common-actions.yaml create mode 100644 tests/components/ezo_pmp/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 3477de88a4e..1157ef1fb01 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -44,91 +44,18 @@ EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( } ) -# Actions that do not require more arguments +for _name, _method in ( + ("ezo_pmp.find", "find()"), + ("ezo_pmp.dose_continuously", "dose_continuously()"), + ("ezo_pmp.clear_total_volume_dosed", "clear_total_volume_dosed()"), + ("ezo_pmp.clear_calibration", "clear_calibration()"), + ("ezo_pmp.pause_dosing", "pause_dosing()"), + ("ezo_pmp.stop_dosing", "stop_dosing()"), +): + automation.register_apply_action( + _name, EZO_PMP_NO_ARGS_ACTION_SCHEMA, automation.ApplyCall(_method) + ) -EzoPMPFindAction = ezo_pmp_ns.class_("EzoPMPFindAction", automation.Action) -EzoPMPClearTotalVolumeDispensedAction = ezo_pmp_ns.class_( - "EzoPMPClearTotalVolumeDispensedAction", automation.Action -) -EzoPMPClearCalibrationAction = ezo_pmp_ns.class_( - "EzoPMPClearCalibrationAction", automation.Action -) -EzoPMPPauseDosingAction = ezo_pmp_ns.class_( - "EzoPMPPauseDosingAction", automation.Action -) -EzoPMPStopDosingAction = ezo_pmp_ns.class_("EzoPMPStopDosingAction", automation.Action) -EzoPMPDoseContinuouslyAction = ezo_pmp_ns.class_( - "EzoPMPDoseContinuouslyAction", automation.Action -) - -# Actions that require more arguments -EzoPMPDoseVolumeAction = ezo_pmp_ns.class_("EzoPMPDoseVolumeAction", automation.Action) -EzoPMPDoseVolumeOverTimeAction = ezo_pmp_ns.class_( - "EzoPMPDoseVolumeOverTimeAction", automation.Action -) -EzoPMPDoseWithConstantFlowRateAction = ezo_pmp_ns.class_( - "EzoPMPDoseWithConstantFlowRateAction", automation.Action -) -EzoPMPSetCalibrationVolumeAction = ezo_pmp_ns.class_( - "EzoPMPSetCalibrationVolumeAction", automation.Action -) -EzoPMPChangeI2CAddressAction = ezo_pmp_ns.class_( - "EzoPMPChangeI2CAddressAction", automation.Action -) -EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( - "EzoPMPArbitraryCommandAction", automation.Action -) - - -automation.register_simple_action( - "ezo_pmp.find", - EzoPMPFindAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.dose_continuously", - EzoPMPDoseContinuouslyAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.clear_total_volume_dosed", - EzoPMPClearTotalVolumeDispensedAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.clear_calibration", - EzoPMPClearCalibrationAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.pause_dosing", - EzoPMPPauseDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_simple_action( - "ezo_pmp.stop_dosing", - EzoPMPStopDosingAction, - EZO_PMP_NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) - - -# Actions that require Multiple Args EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( { @@ -139,21 +66,11 @@ EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume", - EzoPMPDoseVolumeAction, EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "dose_volume", cg.double), ) -async def ezo_pmp_dose_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( @@ -168,24 +85,14 @@ EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_volume_over_time", - EzoPMPDoseVolumeOverTimeAction, EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_volume_over_time({}, {})", + ((CONF_VOLUME, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( @@ -200,26 +107,14 @@ EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.dose_with_constant_flow_rate", - EzoPMPDoseWithConstantFlowRateAction, EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "dose_with_constant_flow_rate({}, {})", + ((CONF_VOLUME_PER_MINUTE, cg.double), (CONF_DURATION, cg.int_)), + ), ) -async def ezo_pmp_dose_with_constant_flow_rate_to_code( - config, action_id, template_arg, args -): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME_PER_MINUTE], args, cg.double) - cg.add(var.set_volume(template_)) - - template_ = await cg.templatable(config[CONF_DURATION], args, cg.int_) - cg.add(var.set_duration(template_)) - - return var EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( @@ -231,21 +126,11 @@ EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.set_calibration_volume", - EzoPMPSetCalibrationVolumeAction, EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_VOLUME, "set_calibration_volume", cg.double), ) -async def ezo_pmp_set_calibration_volume_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_VOLUME], args, cg.double) - cg.add(var.set_volume(template_)) - - return var EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( @@ -255,21 +140,11 @@ EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.change_i2c_address", - EzoPMPChangeI2CAddressAction, EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "change_i2c_address", cg.int_), ) -async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) - cg.add(var.set_address(template_)) - - return var EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( @@ -279,18 +154,8 @@ EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( } ) - -@automation.register_action( +automation.register_apply_action( "ezo_pmp.arbitrary_command", - EzoPMPArbitraryCommandAction, EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_COMMAND, "exec_arbitrary_command", cg.std_string), ) -async def ezo_pmp_arbitrary_command_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - - return var diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 55283f2d097..ab05302a55b 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/i2c/i2c.h" #ifdef USE_BINARY_SENSOR @@ -113,137 +112,4 @@ class EzoPMP final : public PollingComponent, public i2c::I2CDevice { #endif }; -// Action Templates -template class EzoPMPFindAction final : public Action { - public: - EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->find(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseContinuouslyAction final : public Action { - public: - EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_continuously(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeAction final : public Action { - public: - EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->dose_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseVolumeOverTimeAction final : public Action { - public: - EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_volume_over_time(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPDoseWithConstantFlowRateAction final : public Action { - public: - EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { - this->ezopmp_->dose_with_constant_flow_rate(this->volume_.value(x...), this->duration_.value(x...)); - } - TEMPLATABLE_VALUE(double, volume) - TEMPLATABLE_VALUE(int, duration) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPSetCalibrationVolumeAction final : public Action { - public: - EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->set_calibration_volume(this->volume_.value(x...)); } - TEMPLATABLE_VALUE(double, volume) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearTotalVolumeDispensedAction final : public Action { - public: - EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_total_volume_dosed(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPClearCalibrationAction final : public Action { - public: - EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->clear_calibration(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPPauseDosingAction final : public Action { - public: - EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->pause_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPStopDosingAction final : public Action { - public: - EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->stop_dosing(); } - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPChangeI2CAddressAction final : public Action { - public: - EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->change_i2c_address(this->address_.value(x...)); } - TEMPLATABLE_VALUE(int, address) - - protected: - EzoPMP *ezopmp_; -}; - -template class EzoPMPArbitraryCommandAction final : public Action { - public: - EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} - - void play(const Ts &...x) override { this->ezopmp_->exec_arbitrary_command(this->command_.value(x...)); } - TEMPLATABLE_VALUE(std::string, command) - - protected: - EzoPMP *ezopmp_; -}; - } // namespace esphome::ezo_pmp diff --git a/tests/components/ezo_pmp/common-actions.yaml b/tests/components/ezo_pmp/common-actions.yaml new file mode 100644 index 00000000000..2bb94247c63 --- /dev/null +++ b/tests/components/ezo_pmp/common-actions.yaml @@ -0,0 +1,20 @@ +button: + - platform: template + name: EZO PMP Actions + on_press: + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: 104 + - ezo_pmp.change_i2c_address: + id: hcl_pump + address: !lambda return 105; + - ezo_pmp.dose_volume: + id: hcl_pump + volume: !lambda return 2.5; + - ezo_pmp.dose_volume_over_time: + id: hcl_pump + volume: !lambda return 2.5; + duration: !lambda return 3; + - ezo_pmp.arbitrary_command: + id: hcl_pump + command: !lambda return "D,?"; diff --git a/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..602fa34c9cf --- /dev/null +++ b/tests/components/ezo_pmp/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ezo_pmp: !include common.yaml + actions: !include common-actions.yaml From 0c548f77fa34484626afab64118f10593047f444 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:24:57 +0100 Subject: [PATCH 517/759] [bm8563] Use register_apply_action for read_time, write_time and start_timer (#19665) --- esphome/components/bm8563/bm8563.h | 20 ------- esphome/components/bm8563/time.py | 53 +++++-------------- tests/components/bm8563/common-actions.yaml | 7 +++ .../bm8563/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 24 insertions(+), 60 deletions(-) create mode 100644 tests/components/bm8563/common-actions.yaml create mode 100644 tests/components/bm8563/test-actions.esp8266-ard.yaml diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index 5ca9714091d..b9b7a2c3276 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -34,24 +34,4 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->read_time(); } -}; - -template class TimerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto duration = this->duration_.value(x...); - this->parent_->start_timer(duration); - } -}; - } // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 3a15d0eb1c0..c28976eb872 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,8 +3,6 @@ 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"] @@ -13,9 +11,6 @@ I2C_ADDR = 0x51 bm8563_ns = cg.esphome_ns.namespace("bm8563") BM8563 = bm8563_ns.class_("BM8563", time.RealTimeClock, i2c.I2CDevice) -WriteAction = bm8563_ns.class_("WriteAction", automation.Action) -ReadAction = bm8563_ns.class_("ReadAction", automation.Action) -TimerAction = bm8563_ns.class_("TimerAction", automation.Action) CONFIG_SCHEMA = ( time.TIME_SCHEMA.extend( @@ -28,51 +23,29 @@ CONFIG_SCHEMA = ( ) -automation.register_parented_action( - "bm8563.write_time", - WriteAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, +BM8563_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } ) +automation.register_apply_action( + "bm8563.write_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -@automation.register_action( +automation.register_apply_action( + "bm8563.read_time", BM8563_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + +automation.register_apply_action( "bm8563.start_timer", - TimerAction, cv.Schema( { cv.GenerateID(): cv.use_id(BM8563), cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), } ), - synchronous=True, -) -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) - cg.add(var.set_duration(template_)) - return var - - -automation.register_parented_action( - "bm8563.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(BM8563), - } - ), - synchronous=True, + automation.ApplyField(CONF_DURATION, "start_timer", cg.uint32), ) diff --git a/tests/components/bm8563/common-actions.yaml b/tests/components/bm8563/common-actions.yaml new file mode 100644 index 00000000000..c8175e3177a --- /dev/null +++ b/tests/components/bm8563/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: BM8563 Actions + on_press: + - bm8563.read_time: + - bm8563.start_timer: + duration: !lambda return 60; diff --git a/tests/components/bm8563/test-actions.esp8266-ard.yaml b/tests/components/bm8563/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..eaabbc63fe9 --- /dev/null +++ b/tests/components/bm8563/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bm8563: !include common.yaml + actions: !include common-actions.yaml From 6622e1fe64f48021e8ff1c20b8f0b7ec9792a08e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:28:12 +0100 Subject: [PATCH 518/759] [grove_tb6612fng] Use register_apply_action for the six motor actions (#19664) --- .../components/grove_tb6612fng/__init__.py | 129 +++++------------- .../grove_tb6612fng/grove_tb6612fng.h | 58 -------- .../grove_tb6612fng/common-actions.yaml | 22 +++ .../test-actions.esp8266-ard.yaml | 4 + 4 files changed, 57 insertions(+), 156 deletions(-) create mode 100644 tests/components/grove_tb6612fng/common-actions.yaml create mode 100644 tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index e7937b1f298..2d44d883230 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -20,28 +20,9 @@ grove_tb6612fng_ns = cg.esphome_ns.namespace("grove_tb6612fng") GROVE_TB6612FNG = grove_tb6612fng_ns.class_( "GroveMotorDriveTB6612FNG", cg.Component, i2c.I2CDevice ) -GROVETB6612FNGMotorRunAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorRunAction", automation.Action -) -GROVETB6612FNGMotorBrakeAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorBrakeAction", automation.Action -) -GROVETB6612FNGMotorStopAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStopAction", automation.Action -) -GROVETB6612FNGMotorStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorStandbyAction", automation.Action -) -GROVETB6612FNGMotorNoStandbyAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorNoStandbyAction", automation.Action -) -GROVETB6612FNGMotorChangeAddressAction = grove_tb6612fng_ns.class_( - "GROVETB6612FNGMotorChangeAddressAction", automation.Action -) - DIRECTION_TYPE = { "FORWARD": 1, - "BACKWARD": 2, + "BACKWARD": -1, } CONFIG_SCHEMA = ( @@ -61,9 +42,8 @@ async def to_code(config): await i2c.register_i2c_device(var, config) -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.run", - GROVETB6612FNGMotorRunAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), @@ -72,99 +52,52 @@ async def to_code(config): cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), - synchronous=True, + automation.ApplyCall( + "dc_motor_run({}, {} * {})", + ((CONF_CHANNEL, cg.uint8), (CONF_DIRECTION, cg.int16), (CONF_SPEED, cg.uint16)), + ), ) -async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - template_speed = await cg.templatable(config[CONF_SPEED], args, cg.uint16) - cg.add(var.set_channel(template_channel)) - cg.add(var.set_speed(template_speed)) - cg.add(var.set_direction(config[CONF_DIRECTION] == "FORWARD")) - return var +CHANNEL_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), + } +) - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.break", - GROVETB6612FNGMotorBrakeAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_brake", cg.uint8), ) -async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -@automation.register_action( +automation.register_apply_action( "grove_tb6612fng.stop", - GROVETB6612FNGMotorStopAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - } - ), - synchronous=True, -) -async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_channel = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_channel)) - return var - - -automation.register_parented_action( - "grove_tb6612fng.standby", - GROVETB6612FNGMotorStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, + CHANNEL_SCHEMA, + automation.ApplyField(CONF_CHANNEL, "dc_motor_stop", cg.uint8), ) - -automation.register_parented_action( - "grove_tb6612fng.no_standby", - GROVETB6612FNGMotorNoStandbyAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), - } - ), - synchronous=True, +PARENT_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), + } ) +automation.register_apply_action( + "grove_tb6612fng.standby", PARENT_SCHEMA, automation.ApplyCall("standby()") +) -@automation.register_action( +automation.register_apply_action( + "grove_tb6612fng.no_standby", PARENT_SCHEMA, automation.ApplyCall("not_standby()") +) + +automation.register_apply_action( "grove_tb6612fng.change_address", - GROVETB6612FNGMotorChangeAddressAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_ADDRESS): cv.i2c_address, } ), - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "set_i2c_addr", cg.uint8), ) -async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - - template_channel = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_channel)) - return var diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index a8648025b98..08a3ac2374e 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/components/i2c/i2c.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" // #include "esphome/core/helpers.h" @@ -161,61 +160,4 @@ class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { uint8_t buffer_[16]; }; -template -class GROVETB6612FNGMotorRunAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint16_t, speed) - - void set_direction(bool forward) { this->forward_ = forward; } - - void play(const Ts &...x) override { - auto channel = this->channel_.value(x...); - int16_t speed = this->speed_.value(x...); - if (!this->forward_) { - speed = -speed; - } - this->parent_->dc_motor_run(channel, speed); - } - - protected: - bool forward_{true}; -}; - -template -class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_brake(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStopAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - - void play(const Ts &...x) override { this->parent_->dc_motor_stop(this->channel_.value(x...)); } -}; - -template -class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->standby(); } -}; - -template -class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->not_standby(); } -}; - -template -class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, address) - - void play(const Ts &...x) override { this->parent_->set_i2c_addr(this->address_.value(x...)); } -}; - } // namespace esphome::grove_tb6612fng diff --git a/tests/components/grove_tb6612fng/common-actions.yaml b/tests/components/grove_tb6612fng/common-actions.yaml new file mode 100644 index 00000000000..de3537c8f28 --- /dev/null +++ b/tests/components/grove_tb6612fng/common-actions.yaml @@ -0,0 +1,22 @@ +button: + - platform: template + name: Grove Motor Actions + on_press: + - grove_tb6612fng.run: + channel: !lambda return 0; + speed: 100 + direction: FORWARD + id: test_motor + - grove_tb6612fng.stop: + channel: !lambda return 1; + id: test_motor + - grove_tb6612fng.break: + channel: !lambda return 0; + id: test_motor + - grove_tb6612fng.standby: + id: test_motor + - grove_tb6612fng.no_standby: + id: test_motor + - grove_tb6612fng.change_address: + address: 0x15 + id: test_motor diff --git a/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..f29d189fd58 --- /dev/null +++ b/tests/components/grove_tb6612fng/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + grove_tb6612fng: !include common.yaml + actions: !include common-actions.yaml From 9654dd391225976bb8f5a39b1a0d4a0379dc8672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:34:38 +0000 Subject: [PATCH 519/759] Bump platformdirs from 4.11.11 to 4.11.12 (#19668) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 275626b1f07..ff0ad9c944a 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.11 # native esp-idf toolchain global cache dir +platformdirs==4.11.12 # native esp-idf toolchain global cache dir ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 5aeab5f361ea853efd2803a8fb3cde9a4539ecb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:40:42 +0100 Subject: [PATCH 520/759] [hdc302x] Use register_apply_action for heater_on and heater_off (#19673) --- esphome/components/hdc302x/hdc302x.h | 18 ---------- esphome/components/hdc302x/sensor.py | 34 ++++--------------- tests/components/hdc302x/common-actions.yaml | 12 +++++++ .../hdc302x/test-actions.esp8266-ard.yaml | 4 +++ 4 files changed, 22 insertions(+), 46 deletions(-) create mode 100644 tests/components/hdc302x/common-actions.yaml create mode 100644 tests/components/hdc302x/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index cc5343ee89c..0cf8b7a3868 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" @@ -48,21 +47,4 @@ class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, power) - TEMPLATABLE_VALUE(uint32_t, duration) - - void play(const Ts &...x) override { - auto power_val = this->power_.value(x...); - auto duration_val = this->duration_.value(x...); - this->parent_->start_heater(power_val, duration_val); - } -}; - -template class HeaterOffAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_heater(); } -}; - } // namespace esphome::hdc302x diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 31671c5b1e5..2686ba53223 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -18,8 +18,6 @@ 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"] @@ -37,10 +35,6 @@ POWER_MODE_OPTIONS = { "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, } -# Actions -HeaterOnAction = hdc302x_ns.class_("HeaterOnAction", automation.Action) -HeaterOffAction = hdc302x_ns.class_("HeaterOffAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -118,30 +112,14 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( +automation.register_apply_action( "hdc302x.heater_on", - HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "start_heater({}, {})", ((CONF_POWER, cg.uint16), (CONF_DURATION, cg.uint32)) + ), ) -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) - cg.add(var.set_power(template_)) - template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(template_)) - return var - -automation.register_parented_action( - "hdc302x.heater_off", - HeaterOffAction, - HDC302X_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "hdc302x.heater_off", HDC302X_ACTION_SCHEMA, automation.ApplyCall("stop_heater()") ) diff --git a/tests/components/hdc302x/common-actions.yaml b/tests/components/hdc302x/common-actions.yaml new file mode 100644 index 00000000000..9bc3c13312b --- /dev/null +++ b/tests/components/hdc302x/common-actions.yaml @@ -0,0 +1,12 @@ +button: + - platform: template + name: HDC302x Actions + on_press: + - hdc302x.heater_on: + id: hdc302x_sensor + power: 0x03FF + duration: !lambda return 2000; + - hdc302x.heater_on: + id: hdc302x_sensor + power: !lambda return 0x3FFF; + - hdc302x.heater_off: hdc302x_sensor diff --git a/tests/components/hdc302x/test-actions.esp8266-ard.yaml b/tests/components/hdc302x/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..01bf8fac7df --- /dev/null +++ b/tests/components/hdc302x/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + hdc302x: !include common.yaml + actions: !include common-actions.yaml From aef3a586de6444ff738f3dbf17025dfd23fb742b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:40:48 +0100 Subject: [PATCH 521/759] [max6956] Use register_apply_action for set_brightness_global and set_brightness_mode (#19672) --- esphome/components/max6956/__init__.py | 46 ++++--------------- esphome/components/max6956/automation.h | 38 --------------- tests/components/max6956/common-actions.yaml | 14 ++++++ .../max6956/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 27 insertions(+), 75 deletions(-) delete mode 100644 esphome/components/max6956/automation.h create mode 100644 tests/components/max6956/common-actions.yaml create mode 100644 tests/components/max6956/test-actions.esp8266-ard.yaml diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d718999..557f497e1ab 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -29,10 +28,6 @@ max6956_ns = cg.esphome_ns.namespace("max6956") MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) -# Actions -SetCurrentGlobalAction = max6956_ns.class_("SetCurrentGlobalAction", automation.Action) -SetCurrentModeAction = max6956_ns.class_("SetCurrentModeAction", automation.Action) - MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") CURRENT_MODES = { "global": MAX6956_CURRENTMODE.GLOBAL, @@ -103,9 +98,8 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: return var -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_global", - SetCurrentGlobalAction, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(MAX6956), @@ -115,24 +109,12 @@ async def max6956_pin_to_code(config: ConfigType) -> MockObj: }, key=CONF_BRIGHTNESS_GLOBAL, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS_GLOBAL, "set_brightness_global", cg.uint8), + automation.ApplyCall("write_brightness_global()"), ) -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) - cg.add(var.set_brightness_global(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "max6956.set_brightness_mode", - SetCurrentModeAction, cv.maybe_simple_value( { cv.Required(CONF_ID): cv.use_id(MAX6956), @@ -142,18 +124,8 @@ async def max6956_set_brightness_global_to_code( }, key=CONF_BRIGHTNESS_MODE, ), - synchronous=True, + automation.ApplyField( + CONF_BRIGHTNESS_MODE, "set_brightness_mode", MAX6956_CURRENTMODE + ), + automation.ApplyCall("write_brightness_mode()"), ) -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( - config[CONF_BRIGHTNESS_MODE], args, MAX6956_CURRENTMODE - ) - cg.add(var.set_brightness_mode(template_)) - return var diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h deleted file mode 100644 index f1db2e32400..00000000000 --- a/esphome/components/max6956/automation.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/max6956/max6956.h" - -namespace esphome::max6956 { - -template class SetCurrentGlobalAction final : public Action { - public: - SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(uint8_t, brightness_global) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_global(this->brightness_global_.value(x...)); - this->max6956_->write_brightness_global(); - } - - protected: - MAX6956 *max6956_; -}; - -template class SetCurrentModeAction final : public Action { - public: - SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} - - TEMPLATABLE_VALUE(max6956::MAX6956CURRENTMODE, brightness_mode) - - void play(const Ts &...x) override { - this->max6956_->set_brightness_mode(this->brightness_mode_.value(x...)); - this->max6956_->write_brightness_mode(); - } - - protected: - MAX6956 *max6956_; -}; -} // namespace esphome::max6956 diff --git a/tests/components/max6956/common-actions.yaml b/tests/components/max6956/common-actions.yaml new file mode 100644 index 00000000000..04886342c67 --- /dev/null +++ b/tests/components/max6956/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: MAX6956 Actions + on_press: + - max6956.set_brightness_global: 10 + - max6956.set_brightness_global: + id: max6956_1 + brightness_global: !lambda return 5; + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: segment + - max6956.set_brightness_mode: + id: max6956_1 + brightness_mode: !lambda return max6956::GLOBAL; diff --git a/tests/components/max6956/test-actions.esp8266-ard.yaml b/tests/components/max6956/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..96c170a9f0e --- /dev/null +++ b/tests/components/max6956/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max6956: !include common.yaml + actions: !include common-actions.yaml From 0ef68c17fd1eed783cd6014992188d38ace540b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:18 +0100 Subject: [PATCH 522/759] [uart_mux] Use the apply helpers for select_local, select_bridge and is_local (#19671) --- esphome/components/uart_mux/__init__.py | 22 +++++++++---------- esphome/components/uart_mux/uart_mux.h | 28 ------------------------- 2 files changed, 10 insertions(+), 40 deletions(-) diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py index 6c479f0cdd2..f97a7b2cb3e 100644 --- a/esphome/components/uart_mux/__init__.py +++ b/esphome/components/uart_mux/__init__.py @@ -20,9 +20,6 @@ ROUTE_LOCAL = "local" uart_mux_ns = cg.esphome_ns.namespace("uart_mux") UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) -SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) -SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) -IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -70,15 +67,16 @@ UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True -) -automation.register_simple_action( - "uart_mux.select_bridge", - SelectBridgeAction, +automation.register_apply_action( + "uart_mux.select_local", UART_MUX_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("select_local()"), ) -automation.register_simple_condition( - "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +automation.register_apply_action( + "uart_mux.select_bridge", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_bridge()"), +) +automation.register_apply_condition( + "uart_mux.is_local", UART_MUX_ACTION_SCHEMA, "is_local()" ) diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 8e32a166434..5b9815a3caf 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -3,7 +3,6 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/uart/uart_component_esp_idf.h" #include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::uart_mux { @@ -77,32 +76,5 @@ class UARTMux final : public uart::UARTComponent, public Component { bool load_settings_warned_{false}; }; -template class SelectLocalAction final : public Action { - public: - explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_local(); } - - protected: - UARTMux *parent_; -}; - -template class SelectBridgeAction final : public Action { - public: - explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_bridge(); } - - protected: - UARTMux *parent_; -}; - -template class IsLocalCondition final : public Condition { - public: - explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_local(); } - - protected: - UARTMux *parent_; -}; - } // namespace esphome::uart_mux #endif From d8696c4f28ce941815891bf7fa3f84cd8b084bfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:23 +0100 Subject: [PATCH 523/759] [ufire_ise] Use register_apply_action for calibrate_probe_low, calibrate_probe_high and reset (#19670) --- esphome/components/ufire_ise/sensor.py | 54 +++---------------- esphome/components/ufire_ise/ufire_ise.h | 33 ------------ .../components/ufire_ise/common-actions.yaml | 10 ++++ .../ufire_ise/test-actions.esp8266-ard.yaml | 4 ++ 4 files changed, 20 insertions(+), 81 deletions(-) create mode 100644 tests/components/ufire_ise/common-actions.yaml create mode 100644 tests/components/ufire_ise/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index aab30ff50e4..5399692e3cc 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -13,8 +13,6 @@ 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"] @@ -27,15 +25,6 @@ UFireISEComponent = ufire_ise_ns.class_( "UFireISEComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireISECalibrateProbeLowAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeLowAction", automation.Action -) -UFireISECalibrateProbeHighAction = ufire_ise_ns.class_( - "UFireISECalibrateProbeHighAction", automation.Action -) -UFireISEResetAction = ufire_ise_ns.class_("UFireISEResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -89,51 +78,20 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( } ) - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_low", - UFireISECalibrateProbeLowAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_low", cg.float_), ) -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_) - cg.add(var.set_solution(template_)) - return var - -@automation.register_action( +automation.register_apply_action( "ufire_ise.calibrate_probe_high", - UFireISECalibrateProbeHighAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SOLUTION, "calibrate_probe_high", cg.float_), ) -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_) - cg.add(var.set_solution(template_)) - return var - UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent)}) - -automation.register_simple_action( - "ufire_ise.reset", - UFireISEResetAction, - UFIRE_ISE_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ise.reset", UFIRE_ISE_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index 85916f227e5..2393524713c 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,36 +57,4 @@ class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction final : public Action { - public: - UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_low(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISECalibrateProbeHighAction final : public Action { - public: - UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - - void play(const Ts &...x) override { this->parent_->calibrate_probe_high(this->solution_.value(x...)); } - - protected: - UFireISEComponent *parent_; -}; - -template class UFireISEResetAction final : public Action { - public: - UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireISEComponent *parent_; -}; - } // namespace esphome::ufire_ise diff --git a/tests/components/ufire_ise/common-actions.yaml b/tests/components/ufire_ise/common-actions.yaml new file mode 100644 index 00000000000..64a7fb287e4 --- /dev/null +++ b/tests/components/ufire_ise/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: uFire ISE Actions + on_press: + - ufire_ise.calibrate_probe_high: + id: ufire_ise_sensor + solution: !lambda return 10.0; + - ufire_ise.calibrate_probe_low: + id: ufire_ise_sensor + solution: !lambda return 4.0; diff --git a/tests/components/ufire_ise/test-actions.esp8266-ard.yaml b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9dd9c7d45a7 --- /dev/null +++ b/tests/components/ufire_ise/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ufire_ise: !include common.yaml + actions: !include common-actions.yaml From 447a30da0f486d75c3704b43936d3d05be4f3621 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:41:54 +0100 Subject: [PATCH 524/759] [mcp4461] Use register_apply_action for the four wiper actions (#19669) --- esphome/components/mcp4461/output/__init__.py | 49 +++++----------- .../components/mcp4461/output/automation.h | 56 ------------------- .../mcp4461/output/mcp4461_output.h | 8 +++ tests/components/mcp4461/common-actions.yaml | 10 ++++ .../mcp4461/test-actions.esp8266-ard.yaml | 4 ++ 5 files changed, 37 insertions(+), 90 deletions(-) delete mode 100644 esphome/components/mcp4461/output/automation.h create mode 100644 tests/components/mcp4461/common-actions.yaml create mode 100644 tests/components/mcp4461/test-actions.esp8266-ard.yaml diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index d2b4b321742..b95a9e15100 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_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 @@ -121,13 +119,6 @@ async def to_code(config: ConfigType) -> None: # ---- Actions ---- -WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) -WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) -WiperStoreNonvolatileAction = mcp4461_ns.class_( - "WiperStoreNonvolatileAction", automation.Action -) -WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) - WIPER_ACTION_SCHEMA = automation.maybe_simple_id( {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} ) @@ -144,43 +135,33 @@ TERMINAL_ACTION_SCHEMA = cv.Schema( ) -automation.register_simple_action( +def _char_literal(config: ConfigType, value: str) -> str: + return f"'{value}'" + + +automation.register_apply_action( "mcp4461.wiper.increase", - WiperIncreaseAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("increase_wiper()"), ) - -automation.register_simple_action( +automation.register_apply_action( "mcp4461.wiper.decrease", - WiperDecreaseAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("decrease_wiper()"), ) - -automation.register_simple_action( +automation.register_apply_action( "mcp4461.wiper.store_nonvolatile", - WiperStoreNonvolatileAction, WIPER_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("store_nonvolatile()"), ) - -@automation.register_action( +automation.register_apply_action( "mcp4461.wiper.set_terminal", - WiperSetTerminalAction, TERMINAL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_terminal({}, {})", + ((CONF_TERMINAL, cg.char, _char_literal), (CONF_ENABLE, cg.bool_)), + ), ) -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/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h deleted file mode 100644 index 4be317b2f8f..00000000000 --- a/esphome/components/mcp4461/output/automation.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mcp4461_output.h" - -namespace esphome::mcp4461 { - -template class WiperIncreaseAction : public Action { - public: - explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->increase_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperDecreaseAction : public Action { - public: - explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->decrease_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -// Persist the current level to the chip's nonvolatile register immediately — useful with -// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to -// bypass the stability delay of the automatic mirroring. -template class WiperStoreNonvolatileAction : public Action { - public: - explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->store_nonvolatile(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperSetTerminalAction : public Action { - public: - WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) - : wiper_(wiper), terminal_(terminal), enable_(enable) {} - void play(Ts... x) override { - if (this->enable_) { - this->wiper_->enable_terminal(this->terminal_); - } else { - this->wiper_->disable_terminal(this->terminal_); - } - } - - protected: - Mcp4461Wiper *wiper_; - char terminal_; - bool enable_; -}; - -} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 1052369a744..f0af6037e77 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -33,6 +33,14 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedenable_terminal(terminal); + } else { + this->disable_terminal(terminal); + } + } /// @brief Immediately persist the current wiper level to the chip's nonvolatile register /// (independent of the deferred nonvolatile mirroring / its stability delay) void store_nonvolatile(); diff --git a/tests/components/mcp4461/common-actions.yaml b/tests/components/mcp4461/common-actions.yaml new file mode 100644 index 00000000000..09dbccd554d --- /dev/null +++ b/tests/components/mcp4461/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: MCP4461 Actions + on_press: + - mcp4461.wiper.increase: + id: digipot_wiper_2 + - mcp4461.wiper.set_terminal: + id: digipot_wiper_2 + terminal: w + enable: true diff --git a/tests/components/mcp4461/test-actions.esp8266-ard.yaml b/tests/components/mcp4461/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..d489a2feac9 --- /dev/null +++ b/tests/components/mcp4461/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mcp4461: !include common.yaml + actions: !include common-actions.yaml From 4f47913745b01b1487f21892ad9aec9a1752821e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:55:21 +0100 Subject: [PATCH 525/759] [rx8130] Use register_apply_action for read_time and write_time (#19674) --- esphome/components/rx8130/rx8130.h | 10 ---------- esphome/components/rx8130/time.py | 29 +++++++++-------------------- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 0c738a9b78c..2ab3a5ed2b6 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -18,14 +18,4 @@ class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice void stop_(bool stop); }; -template class WriteAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->write_time(); } -}; - -template class ReadAction final : public Action, public Parented { - public: - void play(const Ts... x) override { this->parent_->read_time(); } -}; - } // namespace esphome::rx8130 diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 0e67593fb32..2082c487593 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -9,8 +9,6 @@ CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] rx8130_ns = cg.esphome_ns.namespace("rx8130") RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice) -WriteAction = rx8130_ns.class_("WriteAction", automation.Action) -ReadAction = rx8130_ns.class_("ReadAction", automation.Action) CONFIG_SCHEMA = time.TIME_SCHEMA.extend( @@ -20,27 +18,18 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x32)) -automation.register_parented_action( - "rx8130.write_time", - WriteAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +RX8130_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } ) +automation.register_apply_action( + "rx8130.write_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -automation.register_parented_action( - "rx8130.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +automation.register_apply_action( + "rx8130.read_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("read_time()") ) From 27ea1a90e32de2e8ea30eb553700de38822e9773 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:56:31 +0100 Subject: [PATCH 526/759] [hub75] Use register_apply_action for hub75.set_brightness (#19677) --- esphome/components/hub75/display.py | 20 +++----------------- esphome/components/hub75/hub75_component.h | 8 -------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 3522acf049a..08d068d0f03 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -18,8 +18,7 @@ from esphome.const import ( CONF_ROTATION, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, EnumValue -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import EnumValue import esphome.final_validate as fv from esphome.helpers import add_class_to_obj from esphome.types import ConfigType @@ -167,7 +166,6 @@ ROTATIONS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") -SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -619,9 +617,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -@automation.register_action( +automation.register_apply_action( "hub75.set_brightness", - SetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), @@ -629,16 +626,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.uint8), ) -async def hub75_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) - cg.add(var.set_brightness(template_)) - return var diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 98bc2e52e6f..dbadc56c9df 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -5,7 +5,6 @@ #include #include "esphome/components/display/display_buffer.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -51,13 +50,6 @@ class HUB75Display final : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, brightness) - - void play(const Ts &...x) override { this->parent_->set_brightness(this->brightness_.value(x...)); } -}; - } // namespace esphome::hub75 #endif From d96d0b80f7b972e8c1c0e9a0d87ecc8b23e94cb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:56:49 +0100 Subject: [PATCH 527/759] [cm1106] Use register_apply_action for cm1106.calibrate_zero (#19678) --- esphome/components/cm1106/cm1106.h | 11 ----------- esphome/components/cm1106/sensor.py | 9 ++------- tests/components/cm1106/common-actions.yaml | 5 +++++ tests/components/cm1106/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 tests/components/cm1106/common-actions.yaml create mode 100644 tests/components/cm1106/test-actions.esp8266-ard.yaml diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 844bfdfa880..e381db7b5cd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -23,14 +22,4 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction final : public Action { - public: - CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} - - void play(const Ts &...x) override { this->cm1106_->calibrate_zero(400); } - - protected: - CM1106Component *cm1106_; -}; - } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index cbb0509ce50..bf12bc38d91 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -22,10 +22,6 @@ cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) -CM1106CalibrateZeroAction = cm1106_ns.class_( - "CM1106CalibrateZeroAction", - automation.Action, -) CONFIG_SCHEMA = ( cv.Schema( @@ -70,9 +66,8 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( +automation.register_apply_action( "cm1106.calibrate_zero", - CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("calibrate_zero(400)"), ) diff --git a/tests/components/cm1106/common-actions.yaml b/tests/components/cm1106/common-actions.yaml new file mode 100644 index 00000000000..dd6115d37a5 --- /dev/null +++ b/tests/components/cm1106/common-actions.yaml @@ -0,0 +1,5 @@ +button: + - platform: template + name: CM1106 Calibrate Zero + on_press: + - cm1106.calibrate_zero: diff --git a/tests/components/cm1106/test-actions.esp8266-ard.yaml b/tests/components/cm1106/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..04d7c1aa5d7 --- /dev/null +++ b/tests/components/cm1106/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + cm1106: !include common.yaml + actions: !include common-actions.yaml From 6925c5127e77f9c8821df1314a2f768ad115e997 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:58:34 +0100 Subject: [PATCH 528/759] [ags10] Use register_apply_action for new_i2c_address and set_zero_point (#19676) --- esphome/components/ags10/ags10.cpp | 14 ++++++ esphome/components/ags10/ags10.h | 50 ++++++------------- esphome/components/ags10/sensor.py | 48 +++--------------- tests/components/ags10/common-actions.yaml | 19 +++++++ .../ags10/test-actions.esp8266-ard.yaml | 4 ++ 5 files changed, 58 insertions(+), 77 deletions(-) create mode 100644 tests/components/ags10/common-actions.yaml create mode 100644 tests/components/ags10/test-actions.esp8266-ard.yaml diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 230548ae947..377677ccdb8 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -106,6 +106,20 @@ bool AGS10Component::set_zero_point_with_factory_defaults() { return this->set_z bool AGS10Component::set_zero_point_with_current_resistance() { return this->set_zero_point_with(ZP_CURRENT); } +void AGS10Component::set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value) { + switch (mode) { + case FACTORY_DEFAULT: + this->set_zero_point_with_factory_defaults(); + break; + case CURRENT_VALUE: + this->set_zero_point_with_current_resistance(); + break; + case CUSTOM_VALUE: + this->set_zero_point_with(value); + break; + } +} + bool AGS10Component::set_zero_point_with(uint16_t value) { std::array data{0x00, 0x0C, (uint8_t) ((value >> 8) & 0xFF), (uint8_t) (value & 0xFF), 0}; data[4] = crc8(data.data(), 4, 0xFF, 0x31, true); diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 8ebc8da544a..8b5f51f9711 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::ags10 { +enum AGS10SetZeroPointActionMode { + // Zero-point reset. + FACTORY_DEFAULT, + // Zero-point calibration with current resistance. + CURRENT_VALUE, + // Zero-point calibration with custom resistance. + CUSTOM_VALUE, +}; + class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** @@ -47,6 +55,11 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { */ bool set_zero_point_with_current_resistance(); + /** + * Sets zero-point by mode; the value is only used for CUSTOM_VALUE. + */ + void set_zero_point(AGS10SetZeroPointActionMode mode, uint16_t value); + /** * Sets zero-point with the value. */ @@ -100,39 +113,4 @@ class AGS10Component final : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, new_address) - - void play(const Ts &...x) override { this->parent_->new_i2c_address(this->new_address_.value(x...)); } -}; - -enum AGS10SetZeroPointActionMode { - // Zero-point reset. - FACTORY_DEFAULT, - // Zero-point calibration with current resistance. - CURRENT_VALUE, - // Zero-point calibration with custom resistance. - CUSTOM_VALUE, -}; - -template class AGS10SetZeroPointAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, value) - TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) - - void play(const Ts &...x) override { - switch (this->mode_.value(x...)) { - case FACTORY_DEFAULT: - this->parent_->set_zero_point_with_factory_defaults(); - break; - case CURRENT_VALUE: - this->parent_->set_zero_point_with_current_resistance(); - break; - case CUSTOM_VALUE: - this->parent_->set_zero_point_with(this->value_.value(x...)); - break; - } - } -}; } // namespace esphome::ags10 diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8606e7c2477..fbd4bf590e3 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,8 +17,6 @@ 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" @@ -28,12 +26,6 @@ DEPENDENCIES = ["i2c"] ags10_ns = cg.esphome_ns.namespace("ags10") AGS10Component = ags10_ns.class_("AGS10Component", cg.PollingComponent, i2c.I2CDevice) -# Actions -AGS10NewI2cAddressAction = ags10_ns.class_( - "AGS10NewI2cAddressAction", automation.Action -) -AGS10SetZeroPointAction = ags10_ns.class_("AGS10SetZeroPointAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -91,24 +83,11 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "ags10.new_i2c_address", - AGS10NewI2cAddressAction, AGS10_NEW_I2C_ADDRESS_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ADDRESS, "new_i2c_address", cg.uint8), ) -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) - cg.add(var.set_new_address(address)) - return var - AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") AGS10_SET_ZERO_POINT_ACTION_MODE = { @@ -128,24 +107,11 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ags10.set_zero_point", - AGS10SetZeroPointAction, AGS10_SET_ZERO_POINT_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_zero_point({}, {})", + ((CONF_MODE, AGS10SetZeroPointActionMode), (CONF_VALUE, cg.uint16)), + ), ) -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( - config.get(CONF_MODE), args, AGS10SetZeroPointActionMode - ) - cg.add(var.set_mode(mode)) - value = await cg.templatable(config[CONF_VALUE], args, cg.uint16) - cg.add(var.set_value(value)) - return var diff --git a/tests/components/ags10/common-actions.yaml b/tests/components/ags10/common-actions.yaml new file mode 100644 index 00000000000..6c0e70115b1 --- /dev/null +++ b/tests/components/ags10/common-actions.yaml @@ -0,0 +1,19 @@ +button: + - platform: template + name: AGS10 Zero Point + on_press: + - ags10.set_zero_point: + id: ags10_1 + mode: FACTORY_DEFAULT + - ags10.set_zero_point: + id: ags10_1 + mode: CUSTOM_VALUE + value: 0x1234 + - ags10.set_zero_point: + id: ags10_1 + mode: !lambda return ags10::CURRENT_VALUE; + value: !lambda return 0x2345; + - ags10.new_i2c_address: 0x1B + - ags10.new_i2c_address: + id: ags10_1 + address: !lambda return 0x1C; diff --git a/tests/components/ags10/test-actions.esp8266-ard.yaml b/tests/components/ags10/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..2fb84b12f98 --- /dev/null +++ b/tests/components/ags10/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c_low_freq: !include ../../test_build_components/common/i2c_low_freq/esp8266-ard.yaml + ags10: !include common.yaml + actions: !include common-actions.yaml From a81cf8f5cc2d05c894388ef80c8db6770de3ca9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 20:58:52 +0100 Subject: [PATCH 529/759] [ufire_ec] Use register_apply_action for calibrate_probe and reset (#19675) --- esphome/components/ufire_ec/sensor.py | 36 +++++--------------------- esphome/components/ufire_ec/ufire_ec.h | 25 ------------------ 2 files changed, 7 insertions(+), 54 deletions(-) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 406de3eff35..5bfa959c044 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -14,8 +14,6 @@ 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"] @@ -29,12 +27,6 @@ UFireECComponent = ufire_ec_ns.class_( "UFireECComponent", cg.PollingComponent, i2c.I2CDevice ) -# Actions -UFireECCalibrateProbeAction = ufire_ec_ns.class_( - "UFireECCalibrateProbeAction", automation.Action -) -UFireECResetAction = ufire_ec_ns.class_("UFireECResetAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -96,25 +88,14 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ufire_ec.calibrate_probe", - UFireECCalibrateProbeAction, UFIRE_EC_CALIBRATE_PROBE_SCHEMA, - synchronous=True, + automation.ApplyCall( + "calibrate_probe({}, {})", + ((CONF_SOLUTION, cg.float_), (CONF_TEMPERATURE, cg.float_)), + ), ) -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_) - temperature_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_solution(solution_)) - cg.add(var.set_temperature(temperature_)) - return var UFIRE_EC_RESET_SCHEMA = cv.Schema( @@ -124,9 +105,6 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( ) -automation.register_simple_action( - "ufire_ec.reset", - UFireECResetAction, - UFIRE_EC_RESET_SCHEMA, - synchronous=True, +automation.register_apply_action( + "ufire_ec.reset", UFIRE_EC_RESET_SCHEMA, automation.ApplyCall("reset_board()") ) diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index 0928fda9ee2..a7db2b14253 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" @@ -58,28 +57,4 @@ class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction final : public Action { - public: - UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, solution) - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { - this->parent_->calibrate_probe(this->solution_.value(x...), this->temperature_.value(x...)); - } - - protected: - UFireECComponent *parent_; -}; - -template class UFireECResetAction final : public Action { - public: - UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->reset_board(); } - - protected: - UFireECComponent *parent_; -}; - } // namespace esphome::ufire_ec From e83dc253010494543dee430d53950786c26d37b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Sep 2026 21:28:18 +0100 Subject: [PATCH 530/759] [esp_ldo] Use register_apply_action for esp_ldo.voltage.adjust (#19680) --- esphome/components/esp_ldo/__init__.py | 20 +++----------------- esphome/components/esp_ldo/esp_ldo.h | 13 ------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index 46810d422de..23536fe3a28 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,12 +1,11 @@ from typing import Any -from esphome.automation import Action, register_action +from esphome import automation 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 @@ -16,7 +15,6 @@ DOMAIN = "esp_ldo" esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo") EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component) -AdjustAction = esp_ldo_ns.class_("AdjustAction", Action) CHANNELS = (1, 2, 3, 4) CHANNELS_INTERNAL = (1, 2) @@ -123,9 +121,8 @@ def adjusted_ldo_id(value: Any) -> ID: return value -@register_action( +automation.register_apply_action( "esp_ldo.voltage.adjust", - AdjustAction, cv.Schema( { cv.GenerateID(CONF_ID): adjusted_ldo_id, @@ -134,16 +131,5 @@ def adjusted_ldo_id(value: Any) -> ID: ), } ), - synchronous=True, + automation.ApplyField(CONF_VOLTAGE, "adjust_voltage", cg.float_), ) -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_) - cg.add(var.set_voltage(template_)) - return var diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index 0451c338dd1..0f0131d4d5a 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -1,7 +1,6 @@ #pragma once #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esp_ldo_regulator.h" namespace esphome::esp_ldo { @@ -27,18 +26,6 @@ class EspLdo final : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction final : public Action { - public: - explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} - - TEMPLATABLE_VALUE(float, voltage) - - void play(const Ts &...x) override { this->ldo_->adjust_voltage(this->voltage_.value(x...)); } - - protected: - EspLdo *ldo_; -}; - } // namespace esphome::esp_ldo #endif // USE_ESP32_VARIANT_ESP32P4 From 64388318e837fd5d22f73997e90416781366a395 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:47:25 +1000 Subject: [PATCH 531/759] [light] Replace restore_mode with restore_state (#18997) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/light/__init__.py | 166 +++--- esphome/components/light/automation.py | 85 ++- esphome/components/light/effects.py | 3 +- esphome/components/light/light_state.cpp | 69 +-- esphome/components/light/light_state.h | 46 +- esphome/components/light/restore_state.py | 345 +++++++++++ .../components/light/bench_light_call.cpp | 1 - .../light/hoermann_hcp_light_test.cpp | 13 +- tests/components/light/common.yaml | 79 +++ tests/components/light/test.esp32-idf.yaml | 3 +- tests/components/light/test.esp8266-ard.yaml | 3 +- .../components/light/test.nrf52-adafruit.yaml | 3 +- tests/components/light/test.nrf52-mcumgr.yaml | 3 +- tests/components/light/test.rp2040-ard.yaml | 3 +- .../fixtures/light_initial_state.yaml | 46 ++ .../fixtures/light_restore_state.yaml | 35 ++ tests/integration/test_light_initial_state.py | 18 + tests/integration/test_light_restore_state.py | 116 ++++ .../components/light/test_automation.py | 58 ++ .../components/light/test_restore_state.py | 535 ++++++++++++++++++ 20 files changed, 1463 insertions(+), 167 deletions(-) create mode 100644 esphome/components/light/restore_state.py create mode 100644 tests/integration/fixtures/light_restore_state.yaml create mode 100644 tests/integration/test_light_restore_state.py create mode 100644 tests/unit_tests/components/light/test_automation.py create mode 100644 tests/unit_tests/components/light/test_restore_state.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 428d1abf1f4..328d01a167d 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -10,20 +10,13 @@ from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( - CONF_BLUE, - CONF_BRIGHTNESS, - CONF_COLD_WHITE, CONF_COLD_WHITE_COLOR_TEMPERATURE, - CONF_COLOR_BRIGHTNESS, CONF_COLOR_CORRECT, - CONF_COLOR_MODE, - CONF_COLOR_TEMPERATURE, CONF_DEFAULT_TRANSITION_LENGTH, CONF_EFFECTS, CONF_ENTITY_CATEGORY, CONF_FLASH_TRANSITION_LENGTH, CONF_GAMMA_CORRECT, - CONF_GREEN, CONF_ICON, CONF_ID, CONF_INITIAL_STATE, @@ -35,17 +28,14 @@ from esphome.const import ( CONF_ON_TURN_ON, CONF_OUTPUT_ID, CONF_POWER_SUPPLY, - CONF_RED, CONF_RESTORE_MODE, + CONF_RESTORE_STATE, CONF_RGB_ORDER, - CONF_STATE, CONF_TRIGGER_ID, - CONF_WARM_WHITE, CONF_WARM_WHITE_COLOR_TEMPERATURE, CONF_WEB_SERVER, - CONF_WHITE, ) -from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -64,6 +54,17 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) +from .restore_state import ( + LEGACY_RESTORE_MODES, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + _build_state_lambda, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _legacy_restore_statements, + _restore_state_statements, +) from .types import ( # noqa: F401 AddressableLight, AddressableLightState, @@ -273,17 +274,34 @@ def migrate_channel_colors( def _final_validate(config: ConfigType) -> None: - """Validate all recorded effect name references against their target lights. + """Validate every configured light's own resolved config, and all recorded + effect name references against their target lights. - This runs once per light platform instance. If no light platform is configured, - this never runs — but the ID validator will catch the missing light ID separately. + FINAL_VALIDATE_SCHEMA for a platform-based domain like `light:` runs once for + the whole domain, not once per entry -- `config` is the full list of light + platform entries across the file, not a single light's own config. """ + for light_config in config: + restore_mode = light_config.get(CONF_RESTORE_MODE) + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + if _initial_state_overridden_by_legacy_mode( + legacy, light_config.get(CONF_INITIAL_STATE) + ): + _LOGGER.warning( + "[%s] 'initial_state: state' is ignored because 'restore_mode: %s' " + "always sets the light %s at boot; use 'restore_state:' instead for " + "per-field control", + light_config.get(CONF_NAME) or light_config[CONF_ID], + restore_mode, + "ON" if legacy.cold_boot_state else "OFF", + ) + data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: return - # Drain the lists so we only validate once even though - # FINAL_VALIDATE_SCHEMA runs for each light platform instance. + # Drain the lists so each recorded reference is only validated once. refs = data.effect_refs data.effect_refs = [] cycle_refs = data.effect_cycle_refs @@ -328,18 +346,6 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -LightRestoreMode = light_ns.enum("LightRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_DEFAULT_ON, - "ALWAYS_OFF": LightRestoreMode.LIGHT_ALWAYS_OFF, - "ALWAYS_ON": LightRestoreMode.LIGHT_ALWAYS_ON, - "RESTORE_INVERTED_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_ON, - "RESTORE_AND_OFF": LightRestoreMode.LIGHT_RESTORE_AND_OFF, - "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, -} - # Schema default that also matches the C++ initializer in light_state.h; codegen # skips the setter when the config equals it. DEFAULT_FLASH_TRANSITION_LENGTH = "0s" @@ -354,9 +360,10 @@ LIGHT_SCHEMA = ( cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), - cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( - RESTORE_MODES, upper=True, space="_" + cv.Exclusive(CONF_RESTORE_MODE, "restore"): cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" ), + cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA, cv.Optional(CONF_ON_TURN_ON): auto.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightTurnOnTrigger), @@ -435,6 +442,28 @@ class LightType(enum.IntEnum): ADDRESSABLE = 3 +def _apply_default_restore_mode( + default_restore_mode: str, +) -> Callable[[ConfigType], ConfigType]: + # cv.Exclusive has no default, so apply the default here if neither key is configured. + def validator(config: ConfigType) -> ConfigType: + if CONF_RESTORE_MODE not in config and CONF_RESTORE_STATE not in config: + config[CONF_RESTORE_MODE] = cv.one_of( + *LEGACY_RESTORE_MODES, upper=True, space="_" + )(default_restore_mode) + return config + + return validator + + +_BASE_SCHEMAS: dict[LightType, cv.Schema] = { + LightType.BINARY: BINARY_LIGHT_SCHEMA, + LightType.BRIGHTNESS_ONLY: BRIGHTNESS_ONLY_LIGHT_SCHEMA, + LightType.RGB: RGB_LIGHT_SCHEMA, + LightType.ADDRESSABLE: ADDRESSABLE_LIGHT_SCHEMA, +} + + def light_schema( class_: MockObjClass, type_: LightType, @@ -450,25 +479,14 @@ def light_schema( for key, default, validator in [ (CONF_ENTITY_CATEGORY, entity_category, cv.entity_category), (CONF_ICON, icon, cv.icon), - ( - CONF_RESTORE_MODE, - default_restore_mode, - cv.enum(RESTORE_MODES, upper=True, space="_"), - ), ]: if default is not cv.UNDEFINED: schema[cv.Optional(key, default=default)] = validator - if type_ == LightType.BINARY: - return BINARY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.BRIGHTNESS_ONLY: - return BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.RGB: - return RGB_LIGHT_SCHEMA.extend(schema) - if type_ == LightType.ADDRESSABLE: - return ADDRESSABLE_LIGHT_SCHEMA.extend(schema) - - raise ValueError(f"Invalid light type: {type_}") + result = _BASE_SCHEMAS[type_].extend(schema) + if default_restore_mode is not cv.UNDEFINED: + result.add_extra(_apply_default_restore_mode(default_restore_mode)) + return result def validate_color_temperature_channels(value): @@ -487,31 +505,43 @@ def validate_color_temperature_channels(value): @setup_entity("light") async def setup_light_core_(light_var, config, output_var): - cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE])) + # All 8 legacy restore_mode values, and the restore_state key, are just different + # ways to build the same state callback and save_enabled flag that LightState's + # runtime actually understands. + initial_state_config = config.get(CONF_INITIAL_STATE) + initial_statements = await _initial_state_statements(initial_state_config) - if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None: - # Emit a stateless lambda that constructs the initial state — values live - # in flash as code, not stored in the LightState object (~40 bytes saved). - initial_state = LightStateRTCState( - initial_state_config.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - initial_state_config.get(CONF_STATE, False), - initial_state_config.get(CONF_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_COLOR_BRIGHTNESS, 1.0), - initial_state_config.get(CONF_RED, 1.0), - initial_state_config.get(CONF_GREEN, 1.0), - initial_state_config.get(CONF_BLUE, 1.0), - initial_state_config.get(CONF_WHITE, 1.0), - initial_state_config.get(CONF_COLOR_TEMPERATURE, 1.0), - initial_state_config.get(CONF_COLD_WHITE, 1.0), - initial_state_config.get(CONF_WARM_WHITE, 1.0), + restore_mode = config.get(CONF_RESTORE_MODE) + restore_state_config = config.get(CONF_RESTORE_STATE) + if restore_state_config == RESTORE_STATE_NONE: + # restore_state: none is explicit shorthand for "no restoring at all" -- + restore_state_config = None + + if restore_mode is not None: + legacy = LEGACY_RESTORE_MODES[restore_mode] + initial_statements.extend( + _legacy_cold_boot_statements(legacy, initial_state_config) ) - args = [(LightStateRTCState.operator("ref"), "s")] - lamb = await cg.process_lambda( - Lambda(f"s = {initial_state};"), - args, - return_type=cg.void, + restore_statements = _legacy_restore_statements(legacy) + save_enabled = legacy.save_enabled + elif restore_state_config is not None: + restore_statements = await _restore_state_statements( + restore_state_config, initial_state_config ) - cg.add(light_var.set_initial_state(lamb)) + save_enabled = True + else: + # Neither key configured: no persistence, and no cold-boot forcing either. + restore_statements = [] + save_enabled = False + + if ( + lamb := await _build_state_lambda( + initial_statements, restore_statements, save_enabled + ) + ) is not None: + cg.add(light_var.set_state_callback(lamb)) + if save_enabled: # matches LightState::save_enabled_'s own default of false + cg.add(light_var.set_save_enabled(save_enabled)) if ( default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 5ae26607746..c938742dac3 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any, NamedTuple + from esphome import automation import esphome.codegen as cg from esphome.config import path_context @@ -28,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .types import ( @@ -44,6 +48,28 @@ from .types import ( CONF_INCLUDE_NONE = "include_none" +_STATE_ON_OFF = cv.one_of("ON", "OFF", upper=True) + + +@schema_extractor("one_of") +def validate_light_state(value: Any) -> Any: + """Validate a light on/off state. + + Documented as 'ON'/'OFF', but accepts all boolean forms for backward compatibility. + """ + if value == SCHEMA_EXTRACT: + return ("ON", "OFF") + try: + return _STATE_ON_OFF(value) == "ON" + except cv.Invalid: + pass + try: + return cv.boolean(value) + except cv.Invalid as err: + raise cv.Invalid( + f"Expected 'ON', 'OFF', or a boolean value, got {value!r}" + ) from err + @automation.register_action( "light.toggle", @@ -71,19 +97,56 @@ async def light_toggle_to_code(config, action_id, template_arg, args): return var +class LightStateField(NamedTuple): + """One field of a light's state: the single source for the schemas and boot-time + codegen that deal with it.""" + + conf_key: str + # Member of LightStateRTCState holding this field. + member: str + validator: Callable[[Any], Any] + # The color mode providing this field, if it implies one. + color_mode: MockObj | None = None + templatable: bool = True + + +# In LightStateRTCState member order. +LIGHT_STATE_FIELDS: tuple[LightStateField, ...] = ( + LightStateField(CONF_STATE, "state", validate_light_state), + LightStateField( + CONF_COLOR_MODE, + "color_mode", + cv.enum(COLOR_MODES, upper=True, space="_"), + templatable=False, + ), + LightStateField(CONF_BRIGHTNESS, "brightness", cv.percentage, ColorMode.BRIGHTNESS), + LightStateField( + CONF_COLOR_BRIGHTNESS, "color_brightness", cv.percentage, ColorMode.RGB + ), + LightStateField(CONF_RED, "red", cv.percentage, ColorMode.RGB), + LightStateField(CONF_GREEN, "green", cv.percentage, ColorMode.RGB), + LightStateField(CONF_BLUE, "blue", cv.percentage, ColorMode.RGB), + LightStateField(CONF_WHITE, "white", cv.percentage, ColorMode.WHITE), + LightStateField( + CONF_COLOR_TEMPERATURE, + "color_temp", + cv.color_temperature, + ColorMode.COLOR_TEMPERATURE, + ), + LightStateField( + CONF_COLD_WHITE, "cold_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), + LightStateField( + CONF_WARM_WHITE, "warm_white", cv.percentage, ColorMode.COLD_WARM_WHITE + ), +) + LIGHT_STATE_SCHEMA = cv.Schema( { - cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"), - cv.Optional(CONF_STATE): cv.templatable(cv.boolean), - cv.Optional(CONF_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_BRIGHTNESS): cv.templatable(cv.percentage), - cv.Optional(CONF_RED): cv.templatable(cv.percentage), - cv.Optional(CONF_GREEN): cv.templatable(cv.percentage), - cv.Optional(CONF_BLUE): cv.templatable(cv.percentage), - cv.Optional(CONF_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_COLOR_TEMPERATURE): cv.templatable(cv.color_temperature), - cv.Optional(CONF_COLD_WHITE): cv.templatable(cv.percentage), - cv.Optional(CONF_WARM_WHITE): cv.templatable(cv.percentage), + cv.Optional(field.conf_key): ( + cv.templatable(field.validator) if field.templatable else field.validator + ) + for field in LIGHT_STATE_FIELDS } ) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5d..535def0ba02 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry +from .automation import validate_light_state from .types import ( COLOR_MODES, AddressableColorWipeEffect, @@ -281,7 +282,7 @@ async def random_effect_to_code(config, effect_id): cv.ensure_list( cv.Schema( { - cv.Optional(CONF_STATE, default=True): cv.boolean, + cv.Optional(CONF_STATE, default=True): validate_light_state, cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, cv.Optional(CONF_COLOR_MODE): cv.enum( COLOR_MODES, upper=True, space="_" diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 63c311f5a8d..9cbe9639dbf 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -13,6 +13,20 @@ namespace esphome::light { static const char *const TAG = "light"; +// Colour modes are bitmasks of capabilities. A mode the light doesn't support may be a bare set of +// required capabilities (see restore_state.py's colour mode inference): use the first supported +// mode that provides all of them, or leave it unchanged if there is none. +static ColorMode resolve_color_mode(const LightTraits &traits, ColorMode requested) { + if (requested == ColorMode::UNKNOWN || traits.supports_color_mode(requested)) + return requested; + auto wanted = static_cast(requested); + for (ColorMode mode : traits.get_supported_color_modes()) { + if ((static_cast(mode) & wanted) == wanted) + return mode; + } + return requested; +} + LightState::LightState(LightOutput *output) : output_(output) {} LightTraits LightState::get_traits() { return this->output_->get_traits(); } @@ -40,38 +54,14 @@ void LightState::setup() { auto call = this->make_call(); LightStateRTCState recovered{}; - if (this->initial_state_callback_) { - this->initial_state_callback_(recovered); - this->initial_state_callback_ = nullptr; // One-shot — no longer needed + bool restored = false; + if (this->save_enabled_) { + this->rtc_ = this->make_entity_preference(); + restored = this->rtc_.load(&recovered); } - switch (this->restore_mode_) { - case LIGHT_RESTORE_DEFAULT_OFF: - case LIGHT_RESTORE_DEFAULT_ON: - case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: - case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = this->make_entity_preference(); - // Attempt to load from preferences, else fall back to default values - if (!this->rtc_.load(&recovered)) { - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON); - } else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF || - this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) { - // Inverted restore state - recovered.state = !recovered.state; - } - break; - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - this->rtc_ = this->make_entity_preference(); - this->rtc_.load(&recovered); - recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - case LIGHT_ALWAYS_OFF: - recovered.state = false; - break; - case LIGHT_ALWAYS_ON: - recovered.state = true; - break; + if (this->state_callback_) { + this->state_callback_(recovered, restored); + this->state_callback_ = nullptr; // One-shot — no longer needed } // A light coming up on boot must never end up on-but-invisible: if the resolved restore @@ -82,7 +72,7 @@ void LightState::setup() { recovered.brightness = 1.0f; } - call.set_color_mode_if_supported(recovered.color_mode); + call.set_color_mode_if_supported(resolve_color_mode(traits, recovered.color_mode)); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); call.set_color_brightness_if_supported(recovered.color_brightness); @@ -428,17 +418,14 @@ void LightState::set_transformer_remote_values_(const LightColorValues &target, #endif void LightState::save_remote_values_() { + if (!this->save_enabled_) + return; LightStateRTCState saved; saved.color_mode = this->remote_values.get_color_mode(); - switch (this->restore_mode_) { - case LIGHT_RESTORE_AND_OFF: - case LIGHT_RESTORE_AND_ON: - saved.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); - break; - default: - saved.state = this->remote_values.is_on(); - break; - } + // Always the real on/off status (RESTORE_AND_ON/OFF used to persist a hardcoded + // true/false here instead; harmless, since those modes force `state` again on + // every load regardless of what was saved -- see _legacy_restore_statements). + saved.state = this->remote_values.is_on(); saved.brightness = this->remote_values.get_brightness(); saved.color_brightness = this->remote_values.get_color_brightness(); saved.red = this->remote_values.get_red(); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 22ce9bf198b..4f7d3c93302 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -44,33 +44,7 @@ class LightTargetStateReachedListener { virtual void on_light_target_state_reached() = 0; }; -enum LightRestoreMode : uint8_t { - LIGHT_RESTORE_DEFAULT_OFF, - LIGHT_RESTORE_DEFAULT_ON, - LIGHT_ALWAYS_OFF, - LIGHT_ALWAYS_ON, - LIGHT_RESTORE_INVERTED_DEFAULT_OFF, - LIGHT_RESTORE_INVERTED_DEFAULT_ON, - LIGHT_RESTORE_AND_OFF, - LIGHT_RESTORE_AND_ON, -}; - struct LightStateRTCState { - LightStateRTCState(ColorMode color_mode, bool state, float brightness, float color_brightness, float red, float green, - float blue, float white, float color_temp, float cold_white, float warm_white) - : brightness(brightness), - color_brightness(color_brightness), - red(red), - green(green), - blue(blue), - white(white), - color_temp(color_temp), - cold_white(cold_white), - warm_white(warm_white), - effect(0), - color_mode(color_mode), - state(state) {} - LightStateRTCState() = default; // Group 4-byte aligned members first float brightness{1.0f}; float color_brightness{1.0f}; @@ -211,12 +185,14 @@ class LightState : public EntityBase, public Component { float gamma_uncorrect_lut(float value) const { return value; } #endif // USE_LIGHT_GAMMA_LUT - /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + /// Set the callback that resolves the boot-time state, called once during setup then + /// cleared. `restored` is true only when a persisted state actually loaded, in which + /// case the state argument already holds the loaded values; otherwise it is freshly + /// default-constructed. Values live in flash as code. + void set_state_callback(void (*callback)(LightStateRTCState &, bool restored)) { this->state_callback_ = callback; } - /// 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 &)) { this->initial_state_callback_ = callback; } + /// Set whether this light persists its state to preferences at all. + void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -382,9 +358,9 @@ class LightState : public EntityBase, public Component { */ std::unique_ptr> target_state_reached_listeners_; - /// Callback to populate initial state defaults — called once during setup, then cleared. + /// Callback that resolves the boot-time state — called once during setup, then cleared. /// Values live in flash as function body; no per-instance data storage beyond this pointer. - void (*initial_state_callback_)(LightStateRTCState &){nullptr}; + void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; /// Value for storing the index of the currently active effect. 0 if no effect is active uint32_t active_effect_index_{}; @@ -406,12 +382,12 @@ class LightState : public EntityBase, public Component { bool next_write_{true}; // for effects, true if a transformer (transition) is active. bool is_transformer_active_{false}; + /// Whether this light persists its state to preferences at all. + bool save_enabled_{false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL /// True while the active transformer publishes current_values on an interval from loop(). bool transition_publish_enabled_{false}; #endif - /// Restore mode of the light. - LightRestoreMode restore_mode_; }; } // namespace esphome::light diff --git a/esphome/components/light/restore_state.py b/esphome/components/light/restore_state.py new file mode 100644 index 00000000000..712b4895478 --- /dev/null +++ b/esphome/components/light/restore_state.py @@ -0,0 +1,345 @@ +""" +Functions and classes to translate the `restore_mode:` and `restore_state:` config keys into +the single runtime state callback that `LightState` actually understands. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR_MODE, CONF_STATE +from esphome.core import Lambda +from esphome.cpp_generator import call_lambda +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType + +from .automation import LIGHT_STATE_FIELDS, validate_light_state +from .types import ColorMode, LightStateRTCState + +RESTORE_STATE_KEEP = "KEEP" +RESTORE_STATE_INVERT = "INVERT" +RESTORE_STATE_INITIAL = "INITIAL" +RESTORE_STATE_ALL = "ALL" +RESTORE_STATE_NONE = "NONE" + + +@dataclass(frozen=True) +class LegacyRestoreMode: + cold_boot_state: bool + restore_action: bool | str | None # None = no override, "INVERT", or force-to-bool + save_enabled: bool + + +LEGACY_RESTORE_MODES: dict[str, LegacyRestoreMode] = { + "RESTORE_DEFAULT_OFF": LegacyRestoreMode(False, None, True), + "RESTORE_DEFAULT_ON": LegacyRestoreMode(True, None, True), + "ALWAYS_OFF": LegacyRestoreMode(False, None, False), + "ALWAYS_ON": LegacyRestoreMode(True, None, False), + "RESTORE_INVERTED_DEFAULT_OFF": LegacyRestoreMode( + False, RESTORE_STATE_INVERT, True + ), + "RESTORE_INVERTED_DEFAULT_ON": LegacyRestoreMode(True, RESTORE_STATE_INVERT, True), + "RESTORE_AND_OFF": LegacyRestoreMode(False, False, True), + "RESTORE_AND_ON": LegacyRestoreMode(True, True, True), +} + +# (config key, LightStateRTCState member) for every field, and for every field but `state`. +_ALL_STATE_FIELDS: tuple[tuple[str, str], ...] = tuple( + (field.conf_key, field.member) for field in LIGHT_STATE_FIELDS +) +_STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple( + (conf_key, member) + for conf_key, member in _ALL_STATE_FIELDS + if conf_key != CONF_STATE +) +# Canonical struct-member order, used only to make generated code deterministic -- +# these are independent field assignments, so the actual order never affects behavior. +_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS) + +# A pending `s. = ;` statement, tagged with the member it writes. +StateStatement = tuple[str, str] + + +def _inferred_color_mode(initial_state_config: ConfigType | None) -> str | None: + """A C++ expression for the capabilities needed by the colour fields `initial_state:` + sets, or None if `color_mode:` is given or no colour field is set. + + Colour modes are bitmasks of capabilities, so the result is a bare capability set, + not necessarily a real mode; LightState::setup() resolves it to a mode the light + actually supports. + """ + if not initial_state_config or CONF_COLOR_MODE in initial_state_config: + return None + modes = sorted( + { + str(field.color_mode) + for field in LIGHT_STATE_FIELDS + if field.color_mode is not None + and initial_state_config.get(field.conf_key) is not None + } + ) + if not modes: + return None + mask = " | ".join(f"static_cast({mode})" for mode in modes) + return f"static_cast<{ColorMode}>({mask})" + + +def _partition_state_statements( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> list[str]: + """Split initial/restore statements into what must run unconditionally versus what + depends on `restored`, and render the resulting lambda body lines. + + Fields whose statement is identical in both branches (e.g. RESTORE_AND_ON's + cold-boot and restore-time statements are both "s.state = true;") are hoisted out + of the `restored` branch entirely, so only the fields that actually depend on + `restored` end up inside it -- down to no branch at all when every field overlaps. + A member appearing more than once in the same list keeps only its last statement + (a plain, side-effect-free assignment): matches sequential-execution semantics, + since an earlier write to the same member is always fully overwritten by a later + one in the original code this replaces. + + `save_enabled` is false exactly when `restore_statements` is empty and `restored` + is unconditionally false at the call site (nothing is ever loaded), so the + initial-only branch can skip its `if (!restored)` guard entirely. + """ + # dict() over (member, statement) pairs keeps the *last* entry per member. + initial_map = dict(initial_statements) + restore_map = dict(restore_statements) + # A member outside _MEMBER_ORDER would be silently skipped below instead of + # raising -- catch that here so a typo doesn't turn into wrong state on a device. + assert set(initial_map) <= set(_MEMBER_ORDER) + assert set(restore_map) <= set(_MEMBER_ORDER) + + common: list[str] = [] + only_initial: list[str] = [] + only_restore: list[str] = [] + for member in _MEMBER_ORDER: + initial_stmt = initial_map.get(member) + restore_stmt = restore_map.get(member) + if initial_stmt is not None and initial_stmt == restore_stmt: + common.append(initial_stmt) + continue + if initial_stmt is not None: + only_initial.append(initial_stmt) + if restore_stmt is not None: + only_restore.append(restore_stmt) + + body = common + if only_restore and only_initial: + body += ["if (restored) {", *only_restore, "} else {", *only_initial, "}"] + elif only_restore: + body += ["if (restored) {", *only_restore, "}"] + elif only_initial: + if save_enabled: + body += ["if (!restored) {", *only_initial, "}"] + else: + body += only_initial + return body + + +async def _build_state_lambda( + initial_statements: list[StateStatement], + restore_statements: list[StateStatement], + save_enabled: bool, +) -> Lambda | None: + """ + Combine the initial and restore statements into a single lambda that applies the + correct values to a `LightStateRTCState &s` depending on whether a persisted state + actually loaded. + """ + if not initial_statements and not restore_statements: + return None + body = _partition_state_statements( + initial_statements, restore_statements, save_enabled + ) + args = [(LightStateRTCState.operator("ref"), "s"), (cg.bool_, "restored")] + return await cg.process_lambda(Lambda("\n".join(body)), args, return_type=cg.void) + + +async def _process_value(value: Any, member: str) -> str: + if isinstance(value, Lambda): + return_type = cg.bool_ if member == CONF_STATE else cg.float_ + lamb = await cg.process_lambda(value, [], return_type=return_type) + return call_lambda(lamb) + return cg.safe_exp(value) + + +async def _initial_state_statements( + initial_state_config: ConfigType | None, +) -> list[StateStatement]: + """ + Create assignments for every field the user set in `initial_state:`, in canonical + struct-member order. A field given as `!lambda` is resolved and called immediately, + the same way `light.control`'s own field lambdas are. + """ + if not initial_state_config: + return [] + statements: list[StateStatement] = [] + for conf_key, member in _ALL_STATE_FIELDS: + if (value := initial_state_config.get(conf_key)) is None: + continue + statements.append( + (member, f"s.{member} = {await _process_value(value, member)};") + ) + if (inferred := _inferred_color_mode(initial_state_config)) is not None: + statements.append(("color_mode", f"s.color_mode = {inferred};")) + return statements + + +async def _resolve_initial_value( + conf_key: str, member: str, initial_state_config: ConfigType | None +) -> str: + """ + Return the C++ expression to use for a `restore_state:` field whose value is INITIAL + """ + if ( + initial_state_config is not None + and (value := initial_state_config.get(conf_key)) is not None + ): + return await _process_value(value, member) + if conf_key == CONF_COLOR_MODE and ( + inferred := _inferred_color_mode(initial_state_config) + ): + return inferred + return f"LightStateRTCState{{}}.{member}" + + +async def _restore_state_statements( + restore_state_config: ConfigType, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the user's `restore_state:` config + """ + statements: list[StateStatement] = [] + state = restore_state_config[CONF_STATE] + if state == RESTORE_STATE_INVERT: + statements.append(("state", "s.state = !s.state;")) + elif state == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(CONF_STATE, "state", initial_state_config) + statements.append(("state", f"s.state = {expr};")) + elif state != RESTORE_STATE_KEEP: + statements.append(("state", f"s.state = {cg.safe_exp(state)};")) + for conf_key, member in _STATE_STRUCT_FIELDS: + value = restore_state_config[conf_key] + if value == RESTORE_STATE_INITIAL: + expr = await _resolve_initial_value(conf_key, member, initial_state_config) + elif value == RESTORE_STATE_KEEP: + continue + else: + expr = cg.safe_exp(value) + statements.append((member, f"s.{member} = {expr};")) + return statements + + +def _legacy_restore_statements(mode: LegacyRestoreMode) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: behavior. + """ + if mode.restore_action is None: + return [] + if mode.restore_action == RESTORE_STATE_INVERT: + return [("state", "s.state = !s.state;")] + return [("state", f"s.state = {str(mode.restore_action).lower()};")] + + +def _legacy_cold_boot_statements( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> list[StateStatement]: + """ + Create a list of statements to apply the legacy restore_mode: cold-boot behavior. + """ + existing_state = ( + initial_state_config.get(CONF_STATE) if initial_state_config else None + ) + if existing_state is None: + if not mode.cold_boot_state: + return [] # already matches LightStateRTCState's own default + elif existing_state == mode.cold_boot_state: + return [] # initial_state: already set exactly this value + return [("state", f"s.state = {str(mode.cold_boot_state).lower()};")] + + +def _initial_state_overridden_by_legacy_mode( + mode: LegacyRestoreMode, initial_state_config: ConfigType | None +) -> bool: + """ + Is the user-config `initial_state:` value for `state` overridden by the legacy mode? + """ + if initial_state_config is None or CONF_STATE not in initial_state_config: + return False + return initial_state_config[CONF_STATE] != mode.cold_boot_state + + +def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: + """ + Extend a validator to also accept the `KEEP`/`INITIAL` options + """ + + @schema_extractor("one_of") + def validate(value: Any) -> Any: + if value == SCHEMA_EXTRACT: + # Editor completion: the sentinels plus the wrapped validator's own values, + # if it is an enum. Numeric validators have none to offer. + try: + inner = tuple(validator(SCHEMA_EXTRACT)) + except cv.Invalid: + inner = () + return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL): + return upper + return validator(value) + + return validate + + +@schema_extractor("one_of") +def _validate_restore_state_state(value: Any) -> str | bool: + if value == SCHEMA_EXTRACT: + return ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + *validate_light_state(SCHEMA_EXTRACT), + ) + if isinstance(value, str): + upper = value.strip().upper() + if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL): + return upper + return validate_light_state(value) + + +_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema( + { + cv.Optional(field.conf_key, default=RESTORE_STATE_KEEP): ( + _validate_restore_state_state + if field.conf_key == CONF_STATE + else _keep_or(field.validator) + ) + for field in LIGHT_STATE_FIELDS + } +) + + +@schema_extractor("schema") +def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str: + """ + The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`. + """ + if value == SCHEMA_EXTRACT: + # The `all`/`none` string shorthands have no representation in the extracted + # docs schema; only the per-field mapping form is walked here. + return _RESTORE_STATE_FIELDS_SCHEMA + if isinstance(value, str): + upper = value.strip().upper() + if upper == RESTORE_STATE_ALL: + value = {} + elif upper == RESTORE_STATE_NONE: + return RESTORE_STATE_NONE + return _RESTORE_STATE_FIELDS_SCHEMA(value) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index c1ef0c425e0..cc6871654de 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -39,7 +39,6 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { light.set_default_transition_length(0); light.set_gamma_correct(2.8f); light.set_gamma_table(bench_gamma_2_8_fwd); - light.set_restore_mode(light::LIGHT_ALWAYS_OFF); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp index ed7e81b2797..75b44a3af6e 100644 --- a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -24,14 +24,17 @@ class CountingHoermannHcpLight : public HoermannHcpLight { int writes{0}; }; -// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +// Drives the platform against a real LightState. Boots off (no persistence) by default, which +// 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); + explicit LightFixture(bool boot_on = false) { + if (boot_on) { + this->state.set_state_callback([](light::LightStateRTCState &s, bool /*restored*/) { s.state = true; }); + } 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. @@ -621,10 +624,10 @@ TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarme 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 +// Booting 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}; + LightFixture fixture{/*boot_on=*/true}; connect_controller(fixture.door); fixture.settle(); diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5d78c411e7a..e1135098e4d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -173,15 +173,69 @@ number: id: test_monochromatic_light brightness: !lambda "return x / 100.0;" +output: + - platform: template + id: light_test_restore_state_keep + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_override + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_none + type: binary + write_action: + - lambda: "" + - platform: template + id: light_test_restore_state_initial + type: binary + write_action: + - lambda: "" light: - platform: binary id: test_binary_light name: Binary Light output: light_test_binary + restore_mode: RESTORE_AND_ON effects: - strobe: on_state: - logger.log: Binary light state changed + # Test restore_state: all (shorthand for {} -- restore everything, no overrides) + - platform: binary + id: test_restore_state_keep + name: Restore State Keep + output: light_test_restore_state_keep + restore_state: all + # Test restore_state: with explicit overrides + - platform: binary + id: test_restore_state_override + name: Restore State Override + output: light_test_restore_state_override + restore_state: + state: INVERT + brightness: 50% + # Test restore_state: none (explicit shorthand for "no restoring at all") + - platform: binary + id: test_restore_state_none + name: Restore State None + output: light_test_restore_state_none + restore_state: none + initial_state: + state: true + # Test restore_state: with INITIAL fields (copy initial_state:'s value on restore) + - platform: binary + id: test_restore_state_initial + name: Restore State Initial + output: light_test_restore_state_initial + initial_state: + state: true + restore_state: + state: INITIAL + brightness: INITIAL - platform: monochromatic id: test_monochromatic_light name: Monochromatic Light @@ -282,6 +336,31 @@ light: red: 100% green: 50% blue: 50% + # Test initial_state: with every templatable field set via !lambda (color_mode is + # the only field that isn't templatable, so it's the only one left as a literal) + - platform: rgbww + id: test_rgbww_light_initial_state_lambda + name: RGBWW Light Initial State Lambda + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + cold_white: test_ledc_4 + warm_white: test_ledc_5 + cold_white_color_temperature: 153 mireds + warm_white_color_temperature: 500 mireds + color_interlock: true + initial_state: + state: !lambda "return true;" + color_mode: rgb_cold_warm_white + brightness: !lambda "return 0.8;" + color_brightness: !lambda "return 0.9;" + red: !lambda "return 1.0;" + green: !lambda "return 0.5;" + blue: !lambda "return 0.25;" + white: !lambda "return 0.3;" + color_temperature: !lambda "return 300.0;" + cold_white: !lambda "return 0.6;" + warm_white: !lambda "return 0.4;" # Test StrobeLightEffect with multiple colors - platform: monochromatic id: test_strobe_multiple diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b43187..14300244a73 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc43..7f61b1ebd5a 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 16 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 08f5f398103..76224f9c06e 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad7744..f6a5ce62950 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 5 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 052de0a4e58..34b5be9fdcf 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.yaml @@ -6,6 +6,21 @@ logger: level: DEBUG output: + - platform: template + id: test_inferred_red + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_green + type: float + write_action: + - lambda: "" + - platform: template + id: test_inferred_blue + type: float + write_action: + - lambda: "" - platform: template id: test_red type: float @@ -26,6 +41,11 @@ output: type: float write_action: - lambda: "" + - platform: template + id: test_no_restore_key_output + type: float + write_action: + - lambda: "" light: - platform: rgb @@ -55,3 +75,29 @@ light: color_mode: BRIGHTNESS state: false brightness: 0% + + # With neither restore_mode nor restore_state configured, initial_state: must be + # honored as-is (no implicit forcing to OFF) -- unlike every explicit restore_mode: + # value, which always overrides `state` regardless of initial_state. + - platform: monochromatic + name: "Test No Restore Key Light" + id: test_no_restore_key_light + output: test_no_restore_key_output + initial_state: + color_mode: BRIGHTNESS + state: true + brightness: 60% + + # No color_mode: it must be inferred from the colour fields, not left unknown (which + # would drop the colours and leave the light full white). + - platform: rgb + name: "Test Inferred Color Mode Light" + id: test_inferred_color_mode_light + red: test_inferred_red + green: test_inferred_green + blue: test_inferred_blue + initial_state: + state: true + red: 30% + green: 0% + blue: 0% diff --git a/tests/integration/fixtures/light_restore_state.yaml b/tests/integration/fixtures/light_restore_state.yaml new file mode 100644 index 00000000000..ac928a76c60 --- /dev/null +++ b/tests/integration/fixtures/light_restore_state.yaml @@ -0,0 +1,35 @@ +esphome: + name: host-light-restore-state-test +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: out_keep + type: float + write_action: + - lambda: "" + - platform: template + id: out_override + type: float + write_action: + - lambda: "" + +light: + # restore_state: {} -- restore everything exactly as saved, no overrides + - platform: monochromatic + name: "Test Light Keep" + id: test_light_keep + output: out_keep + restore_state: {} + + # restore_state: with explicit overrides -- these must win over whatever was saved + - platform: monochromatic + name: "Test Light Override" + id: test_light_override + output: out_override + restore_state: + state: INVERT + brightness: 100% diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 12ebf7c4a1a..12d17bdee6c 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -43,3 +43,21 @@ async def test_light_initial_state( restore_and_on_state = helper.initial_states[restore_and_on_light.key] assert restore_and_on_state.state is True assert restore_and_on_state.brightness == pytest.approx(1.0) + + # With neither restore_mode nor restore_state configured, initial_state: must + # be honored as-is -- unlike every explicit restore_mode: value, omitting both + # keys entirely must not force the light off. + no_restore_key_light = require_entity(entities, "test_no_restore_key_light") + no_restore_key_state = helper.initial_states[no_restore_key_light.key] + assert no_restore_key_state.state is True + assert no_restore_key_state.brightness == pytest.approx(0.6, abs=0.01) + + # color_mode omitted: inferred from the colour fields, so they are applied (the + # light comes up red rather than the default white). RGB values are normalised to + # proportions, so 30% red on its own is pure red. + inferred_light = require_entity(entities, "test_inferred_color_mode_light") + inferred_state = helper.initial_states[inferred_light.key] + assert inferred_state.state is True + assert inferred_state.red == pytest.approx(1.0, abs=0.01) + assert inferred_state.green == pytest.approx(0.0, abs=0.01) + assert inferred_state.blue == pytest.approx(0.0, abs=0.01) diff --git a/tests/integration/test_light_restore_state.py b/tests/integration/test_light_restore_state.py new file mode 100644 index 00000000000..57727938643 --- /dev/null +++ b/tests/integration/test_light_restore_state.py @@ -0,0 +1,116 @@ +"""Integration test for the light `restore_state:` key. + +Tests that: +1. On first boot (nothing saved yet), lights come up at their hardware default (off) -- + restore_state overrides never apply before anything is ever saved. +2. After a state is saved and the device restarts, a `restore_state: {}` light comes + back exactly as saved, while a light with explicit overrides applies those overrides + on top of the loaded state regardless of what was saved. +""" + +from __future__ import annotations + +import asyncio +import socket +from typing import Any + +from aioesphomeapi import LightInfo, LightState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "host-light-restore-state-test" + + +@pytest.mark.asyncio +async def test_light_restore_state( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test restore_state: {} vs restore_state: with overrides, across a restart.""" + port, port_socket = reserved_tcp_port + + clear_host_prefs(DEVICE_NAME) + + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + port_socket.close() + + # --- First run: nothing saved yet, both lights must come up off --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + # A single subscription serves both initial-state capture and later + # state-change waits -- a second subscribe_states call would restart the + # device's initial-state iterator and could resolve a wait on replayed data. + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + keep_initial = helper.initial_states[keep_light.key] + assert isinstance(keep_initial, LightState) + assert keep_initial.state is False + + override_initial = helper.initial_states[override_light.key] + assert isinstance(override_initial, LightState) + assert override_initial.state is False + + # Set both lights to a known, saved state + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + client.light_command(key=keep_light.key, state=True, brightness=0.64) + await wait_for_state(keep_light.key) + + client.light_command(key=override_light.key, state=True, brightness=0.3) + await wait_for_state(override_light.key) + + # --- Second run: same binary, same prefs file --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + keep_light = require_entity(entities, "test_light_keep", LightInfo) + override_light = require_entity(entities, "test_light_override", LightInfo) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda s: None)) + await helper.wait_for_initial_states() + + # restore_state: {} -- comes back exactly as saved + keep_state = helper.initial_states[keep_light.key] + assert isinstance(keep_state, LightState) + assert keep_state.state is True + assert keep_state.brightness == pytest.approx(0.64, abs=0.01) + + # restore_state: with overrides -- state inverted, brightness forced to 100% + # regardless of what was actually saved (0.3) + override_state = helper.initial_states[override_light.key] + assert isinstance(override_state, LightState) + assert override_state.state is False + assert override_state.brightness == pytest.approx(1.0, abs=0.01) + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/unit_tests/components/light/test_automation.py b/tests/unit_tests/components/light/test_automation.py new file mode 100644 index 00000000000..6a2a05227d3 --- /dev/null +++ b/tests/unit_tests/components/light/test_automation.py @@ -0,0 +1,58 @@ +"""Tests for validate_light_state -- a light on/off value that prioritizes ON/OFF +string forms over generic boolean forms, while still accepting the latter.""" + +import pytest +import yaml + +from esphome.components.light.automation import validate_light_state +import esphome.config_validation as cv +from esphome.schema_extractors import SCHEMA_EXTRACT + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("ON", True), + ("on", True), + ("On", True), + ("OFF", False), + ("off", False), + ("Off", False), + (True, True), + (False, False), + ("true", True), + ("false", False), + ("yes", True), + ("no", False), + ], +) +def test_validate_light_state_accepts_on_off_and_booleans( + value: str | bool, expected: bool +) -> None: + assert validate_light_state(value) is expected + + +def test_validate_light_state_rejects_invalid_string() -> None: + # The error must mention both accepted forms (ON/OFF and boolean), not just + # whichever validator happened to run last. + with pytest.raises(cv.Invalid, match="ON.*OFF.*boolean"): + validate_light_state("maybe") + + +def test_validate_light_state_schema_extractor_reports_on_off() -> None: + assert validate_light_state(SCHEMA_EXTRACT) == ("ON", "OFF") + + +def test_validate_light_state_quoted_on_off_survive_yaml_parsing() -> None: + """The default PyYAML resolver treats bareword on/off/yes/no as booleans, so a real + YAML config must quote 'ON'/'OFF' for validate_light_state's string-matching branch + to ever see a string at all -- an unquoted `state: on` already arrives as a native + bool. Both forms must still validate to the same result. + """ + quoted = yaml.safe_load('state: "ON"')["state"] + assert quoted == "ON" + assert validate_light_state(quoted) is True + + unquoted = yaml.safe_load("state: on")["state"] + assert unquoted is True # PyYAML already converted it before validation runs + assert validate_light_state(unquoted) is True diff --git a/tests/unit_tests/components/light/test_restore_state.py b/tests/unit_tests/components/light/test_restore_state.py new file mode 100644 index 00000000000..aee3c5ec194 --- /dev/null +++ b/tests/unit_tests/components/light/test_restore_state.py @@ -0,0 +1,535 @@ +"""Tests for the restore_mode/restore_state -> runtime lambda translation layer.""" + +import logging + +import pytest + +import esphome.codegen as cg +from esphome.components.light import ( + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + LIGHT_SCHEMA, + LightType, + _final_validate, + light_schema, +) +from esphome.components.light.restore_state import ( + _RESTORE_STATE_FIELDS_SCHEMA, + LEGACY_RESTORE_MODES, + RESTORE_STATE_INITIAL, + RESTORE_STATE_INVERT, + RESTORE_STATE_KEEP, + RESTORE_STATE_NONE, + RESTORE_STATE_SCHEMA, + StateStatement, + _initial_state_overridden_by_legacy_mode, + _initial_state_statements, + _legacy_cold_boot_statements, + _partition_state_statements, + _restore_state_statements, + _validate_restore_state_state, +) +from esphome.components.light.types import ColorMode +import esphome.config_validation as cv +from esphome.const import CONF_STATE +from esphome.core import Lambda +from esphome.schema_extractors import SCHEMA_EXTRACT + +# (mode name, expected cold_boot_state, expected restore_action, expected save_enabled) +LEGACY_MODE_TABLE = [ + ("RESTORE_DEFAULT_OFF", False, None, True), + ("RESTORE_DEFAULT_ON", True, None, True), + ("ALWAYS_OFF", False, None, False), + ("ALWAYS_ON", True, None, False), + ("RESTORE_INVERTED_DEFAULT_OFF", False, "INVERT", True), + ("RESTORE_INVERTED_DEFAULT_ON", True, "INVERT", True), + ("RESTORE_AND_OFF", False, False, True), + ("RESTORE_AND_ON", True, True, True), +] + + +@pytest.mark.parametrize( + ("mode", "cold_boot_state", "restore_action", "save_enabled"), LEGACY_MODE_TABLE +) +def test_legacy_restore_mode_translation( + mode: str, cold_boot_state: bool, restore_action, save_enabled: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert legacy.cold_boot_state is cold_boot_state + assert legacy.restore_action == restore_action + assert legacy.save_enabled is save_enabled + + +def test_all_eight_legacy_modes_present() -> None: + assert set(LEGACY_RESTORE_MODES) == {mode for mode, *_ in LEGACY_MODE_TABLE} + + +def test_restore_mode_and_restore_state_are_exclusive() -> None: + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_neither_restore_key_required_or_defaulted() -> None: + config = LIGHT_SCHEMA({"name": "test"}) + assert CONF_RESTORE_MODE not in config + assert CONF_RESTORE_STATE not in config + + +def test_restore_state_empty_config_keeps_everything() -> None: + config = RESTORE_STATE_SCHEMA({}) + assert all(value == RESTORE_STATE_KEEP for value in config.values()) + + +@pytest.mark.parametrize("value", ["all", "All", "ALL"]) +def test_restore_state_all_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_SCHEMA({}) + + +def test_restore_state_rejects_other_strings() -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA("everything") + + +_DummyLight = cg.esphome_ns.class_("DummyLight") + + +def test_default_restore_mode_applies_when_neither_key_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + assert CONF_RESTORE_STATE not in config + + +def test_default_restore_mode_is_dropped_when_restore_state_given() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + config = schema({"id": "light1", "restore_state": {"state": "INVERT"}}) + assert CONF_RESTORE_MODE not in config + assert config[CONF_RESTORE_STATE][CONF_STATE] == "INVERT" + + +def test_default_restore_mode_still_exclusive_with_explicit_restore_state() -> None: + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + with pytest.raises(cv.Invalid, match="restore"): + schema( + { + "id": "light1", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: {}, + } + ) + + +def test_default_restore_mode_result_still_extendable() -> None: + # light_schema() must keep returning a real cv.Schema (not e.g. cv.All) even when + # default_restore_mode is given, since every in-tree light platform chains + # .extend() on its result. + schema = light_schema( + _DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON" + ) + extended = schema.extend({}) + config = extended({"id": "light1"}) + assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON" + + +@pytest.mark.parametrize("value", ["none", "None", "NONE"]) +def test_restore_state_none_shorthand_is_case_insensitive(value: str) -> None: + assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_NONE + + +def test_restore_state_none_is_still_exclusive_with_restore_mode() -> None: + # cv.Exclusive checks which keys are present, regardless of their resolved + # value, so restore_state: none must still conflict with restore_mode:. + with pytest.raises(cv.Invalid, match="restore"): + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_ON", + CONF_RESTORE_STATE: "none", + } + ) + + +def test_restore_state_explicit_overrides_leave_others_keep() -> None: + config = RESTORE_STATE_SCHEMA({"state": "invert", "brightness": "100%"}) + assert config["state"] == "INVERT" + assert config["brightness"] == pytest.approx(1.0) + assert config["color_mode"] == RESTORE_STATE_KEEP + assert config["red"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["keep", "Keep", "KEEP"]) +def test_restore_state_state_accepts_keep_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == RESTORE_STATE_KEEP + + +@pytest.mark.parametrize("value", ["invert", "Invert", "INVERT"]) +def test_restore_state_state_accepts_invert_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INVERT" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_state_accepts_initial_case_insensitively(value: str) -> None: + assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INITIAL" + + +@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"]) +def test_restore_state_other_fields_accept_initial_case_insensitively( + value: str, +) -> None: + assert RESTORE_STATE_SCHEMA({"brightness": value})["brightness"] == "INITIAL" + + +@pytest.mark.parametrize( + ("value", "expected"), [("ON", True), ("OFF", False), (True, True), (False, False)] +) +def test_restore_state_state_prioritizes_on_off( + value: str | bool, expected: bool +) -> None: + # A quoted "ON"/"OFF" string, distinct from KEEP/INVERT, still resolves via + # validate_light_state -- matching initial_state:'s own state field. + assert RESTORE_STATE_SCHEMA({"state": value})["state"] is expected + + +def test_validate_restore_state_state_schema_extract_reports_all_options() -> None: + # Regression test: SCHEMA_EXTRACT is an object() sentinel, not a str, so a naive + # isinstance(value, str) check falls through to validate_light_state() and silently + # drops KEEP/INVERT/INITIAL from the extracted docs schema. + assert _validate_restore_state_state(SCHEMA_EXTRACT) == ( + RESTORE_STATE_KEEP, + RESTORE_STATE_INVERT, + RESTORE_STATE_INITIAL, + "ON", + "OFF", + ) + + +def test_restore_state_schema_extract_returns_fields_schema() -> None: + # The `all`/`none` shorthands aren't representable here; extraction only walks + # the per-field mapping form, so it must resolve to the real fields schema + # rather than falling through to the untyped/unknown bucket. + assert RESTORE_STATE_SCHEMA(SCHEMA_EXTRACT) is _RESTORE_STATE_FIELDS_SCHEMA + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("state", Lambda("return true;")), + ("brightness", Lambda("return 1.0;")), + ("color_mode", Lambda("return light::ColorMode::ON_OFF;")), + ], +) +def test_restore_state_fields_reject_lambda(field: str, value: Lambda) -> None: + with pytest.raises(cv.Invalid): + RESTORE_STATE_SCHEMA({field: value}) + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state:, cold_boot_state False -- already matches + # LightStateRTCState's own `state{false}` default, nothing to emit. + ("ALWAYS_OFF", None, []), + ("ALWAYS_OFF", {}, []), + # No initial_state:, cold_boot_state True -- differs from the default. + ("ALWAYS_ON", None, [("state", "s.state = true;")]), + # initial_state: already set exactly the cold-boot value -- redundant. + ("ALWAYS_OFF", {CONF_STATE: False}, []), + ("RESTORE_AND_ON", {CONF_STATE: True}, []), + # initial_state: set a different value -- must be overridden. + ("ALWAYS_OFF", {CONF_STATE: True}, [("state", "s.state = false;")]), + ("RESTORE_AND_ON", {CONF_STATE: False}, [("state", "s.state = true;")]), + ], +) +def test_legacy_cold_boot_statements_skips_redundant_defaults( + mode: str, initial_state_config: dict | None, expected: list[StateStatement] +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert _legacy_cold_boot_statements(legacy, initial_state_config) == expected + + +def test_partition_no_overlap_produces_full_if_else() -> None: + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [("state", "s.state = false;")], + True, + ) + assert body == [ + "if (restored) {", + "s.state = false;", + "} else {", + "s.brightness = 1.0f;", + "}", + ] + + +def test_partition_full_overlap_drops_branch_entirely() -> None: + # Both branches want exactly the same thing (e.g. RESTORE_AND_ON): no `if` at all. + body = _partition_state_statements( + [("state", "s.state = true;")], + [("state", "s.state = true;")], + True, + ) + assert body == ["s.state = true;"] + + +def test_partition_partial_overlap_hoists_shared_field() -> None: + # `state` matches in both branches and is hoisted out; `red`/`color_mode` differ + # per branch and stay inside a single-sided `if` each. + body = _partition_state_statements( + [ + ("red", "s.red = 0.5f;"), + ("state", "s.state = true;"), + ], + [ + ("state", "s.state = true;"), + ("color_mode", "s.color_mode = light::ColorMode::ON_OFF;"), + ], + True, + ) + assert body == [ + "s.state = true;", + "if (restored) {", + "s.color_mode = light::ColorMode::ON_OFF;", + "} else {", + "s.red = 0.5f;", + "}", + ] + + +def test_partition_duplicate_member_in_one_list_keeps_last_write() -> None: + # initial_statements can legitimately contain two writes to `state`: the user's own + # initial_state: value, followed by a legacy mode's cold-boot override. The earlier + # one is dead code (immediately overwritten) and must not survive partitioning. + body = _partition_state_statements( + [ + ("state", "s.state = true;"), + ("state", "s.state = false;"), + ], + [("state", "s.state = false;")], + True, + ) + assert body == ["s.state = false;"] + + +def test_partition_initial_only_without_save_skips_guard() -> None: + # save_enabled False means restored is unconditionally false at the call site + # (e.g. ALWAYS_OFF, restore_state: none, or neither key configured), so guarding + # the initial-only statements behind `if (!restored)` would only waste flash. + body = _partition_state_statements( + [("brightness", "s.brightness = 1.0f;")], + [], + False, + ) + assert body == ["s.brightness = 1.0f;"] + + +@pytest.mark.parametrize( + ("mode", "initial_state_config", "expected"), + [ + # No initial_state: at all -- nothing to override. + ("ALWAYS_OFF", None, False), + ("ALWAYS_ON", None, False), + # initial_state: set, but doesn't include state -- nothing to override. + ("ALWAYS_OFF", {}, False), + # initial_state: state already matches the mode's cold-boot value -- no-op. + ("ALWAYS_OFF", {CONF_STATE: False}, False), + ("RESTORE_AND_ON", {CONF_STATE: True}, False), + # initial_state: state set to something the mode's cold-boot force overrides. + ("ALWAYS_OFF", {CONF_STATE: True}, True), + ("RESTORE_AND_ON", {CONF_STATE: False}, True), + ], +) +def test_initial_state_overridden_by_legacy_mode( + mode: str, initial_state_config: dict | None, expected: bool +) -> None: + legacy = LEGACY_RESTORE_MODES[mode] + assert ( + _initial_state_overridden_by_legacy_mode(legacy, initial_state_config) + == expected + ) + + +def test_final_validate_warns_when_restore_mode_overrides_initial_state( + caplog: pytest.LogCaptureFixture, +) -> None: + # Regression test: this warning used to fire from setup_light_core_() during + # codegen; it now runs as part of FINAL_VALIDATE_SCHEMA instead, so it also + # surfaces on a plain `esphome config`, not just a full compile. + # + # FINAL_VALIDATE_SCHEMA for the `light:` domain runs once for the whole list + # of configured lights, not once per light -- pass a one-element list, matching + # the real call shape, not the single light's own config dict. + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": True}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert "'initial_state: state' is ignored" in caplog.text + assert "restore_mode: ALWAYS_OFF" in caplog.text + + +def test_final_validate_does_not_warn_without_conflict( + caplog: pytest.LogCaptureFixture, +) -> None: + config = [ + LIGHT_SCHEMA( + { + "name": "test", + CONF_RESTORE_MODE: "ALWAYS_OFF", + "initial_state": {"state": False}, + } + ) + ] + with caplog.at_level(logging.WARNING): + _final_validate(config) + assert caplog.text == "" + + +@pytest.mark.asyncio +async def test_restore_state_initial_state_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + statements = await _restore_state_statements( + restore_state_config, {CONF_STATE: True} + ) + assert statements == [("state", "s.state = true;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_other_field_copies_initial_state_value() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": 0.5} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = 0.5f;")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_to_struct_default() -> None: + # No initial_state: at all -- INITIAL resolves to a read of LightStateRTCState's + # own member-initializer default, straight from the struct. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + statements = await _restore_state_statements(restore_state_config, None) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_falls_back_when_initial_state_omits_field() -> ( + None +): + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"state": True} # doesn't set brightness + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [ + ("brightness", "s.brightness = LightStateRTCState{}.brightness;") + ] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_initial_state() -> None: + # initial_state: gave `state` as a lambda -- INITIAL must call it (and cast its + # result), not just copy a literal value. + restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"}) + initial_state_config = {CONF_STATE: Lambda("return true;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("state", "s.state = static_cast(true);")] + + +@pytest.mark.asyncio +async def test_restore_state_initial_resolves_templated_non_boolean_field() -> None: + # Same as above, but for a float-valued field -- the lambda's return type must + # be float, not bool. + restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"}) + initial_state_config = {"brightness": Lambda("return 0.75;")} + statements = await _restore_state_statements( + restore_state_config, initial_state_config + ) + assert statements == [("brightness", "s.brightness = static_cast(0.75);")] + + +def _mask(*modes: str) -> str: + casts = " | ".join(f"static_cast(light::ColorMode::{m})" for m in modes) + return f"static_cast({casts})" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("initial_state_config", "expected"), + [ + ({CONF_STATE: True}, None), + ({"red": 0.3}, _mask("RGB")), + ({"red": 0.3, "green": 0.0, "color_brightness": 0.5}, _mask("RGB")), + ({"brightness": 0.5}, _mask("BRIGHTNESS")), + ({"white": 0.5}, _mask("WHITE")), + ({"cold_white": 0.5, "warm_white": 0.5}, _mask("COLD_WARM_WHITE")), + ( + {"red": 0.3, "white": 0.5}, + _mask("RGB", "WHITE"), + ), + ({"red": Lambda("return 0.3;")}, _mask("RGB")), + ], +) +async def test_initial_state_infers_color_mode_from_colour_fields( + initial_state_config: dict, expected: str | None +) -> None: + statements = dict(await _initial_state_statements(initial_state_config)) + if expected is None: + assert "color_mode" not in statements + else: + assert statements["color_mode"] == f"s.color_mode = {expected};" + + +@pytest.mark.asyncio +async def test_initial_state_explicit_color_mode_is_not_inferred() -> None: + statements = dict( + await _initial_state_statements({"color_mode": ColorMode.RGB, "red": 0.3}) + ) + assert statements["color_mode"] == "s.color_mode = light::ColorMode::RGB;" + + +@pytest.mark.asyncio +async def test_restore_state_initial_color_mode_uses_inferred_mode() -> None: + restore_state_config = RESTORE_STATE_SCHEMA({"color_mode": "initial"}) + statements = await _restore_state_statements(restore_state_config, {"red": 0.3}) + assert statements == [("color_mode", f"s.color_mode = {_mask('RGB')};")] + + +def test_every_restore_state_field_offers_completion() -> None: + # Regression test: each field's validator must answer SCHEMA_EXTRACT (instead of + # raising), or the editor gets no completion for it. + for key, validator in _RESTORE_STATE_FIELDS_SCHEMA.schema.items(): + values = validator(SCHEMA_EXTRACT) + assert RESTORE_STATE_KEEP in values, key + assert RESTORE_STATE_INITIAL in values, key + + +def test_restore_state_color_mode_completion_includes_color_modes() -> None: + validator = _RESTORE_STATE_FIELDS_SCHEMA.schema[ + next(k for k in _RESTORE_STATE_FIELDS_SCHEMA.schema if k == "color_mode") + ] + values = validator(SCHEMA_EXTRACT) + assert {"KEEP", "INITIAL", "RGB", "COLD_WARM_WHITE"} <= set(values) From d5d0901e2c3d96e06fc2b139d127ddbf675782a2 Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Fri, 25 Sep 2026 18:55:14 -0400 Subject: [PATCH 532/759] [tfluna] New component for the TF-Luna Low Cost LiDAR Distance Sensor (#9199) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tfluna/__init__.py | 34 ++++ esphome/components/tfluna/button/__init__.py | 72 ++++++++ .../components/tfluna/button/reset_button.cpp | 7 + .../components/tfluna/button/reset_button.h | 13 ++ .../tfluna/button/restart_button.cpp | 7 + .../components/tfluna/button/restart_button.h | 13 ++ esphome/components/tfluna/sensor.py | 75 +++++++++ esphome/components/tfluna/text_sensor.py | 23 +++ esphome/components/tfluna/tfluna.cpp | 158 ++++++++++++++++++ esphome/components/tfluna/tfluna.h | 57 +++++++ tests/components/tfluna/common.yaml | 24 +++ tests/components/tfluna/test.esp32-idf.yaml | 4 + tests/components/tfluna/test.esp8266-ard.yaml | 4 + tests/components/tfluna/test.rp2040-ard.yaml | 4 + 15 files changed, 496 insertions(+) create mode 100644 esphome/components/tfluna/__init__.py create mode 100644 esphome/components/tfluna/button/__init__.py create mode 100644 esphome/components/tfluna/button/reset_button.cpp create mode 100644 esphome/components/tfluna/button/reset_button.h create mode 100644 esphome/components/tfluna/button/restart_button.cpp create mode 100644 esphome/components/tfluna/button/restart_button.h create mode 100644 esphome/components/tfluna/sensor.py create mode 100644 esphome/components/tfluna/text_sensor.py create mode 100644 esphome/components/tfluna/tfluna.cpp create mode 100644 esphome/components/tfluna/tfluna.h create mode 100644 tests/components/tfluna/common.yaml create mode 100644 tests/components/tfluna/test.esp32-idf.yaml create mode 100644 tests/components/tfluna/test.esp8266-ard.yaml create mode 100644 tests/components/tfluna/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 4065e52c2c7..c582728e76b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -562,6 +562,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/tfluna/* @candrews esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py new file mode 100644 index 00000000000..6ba821b1ab8 --- /dev/null +++ b/esphome/components/tfluna/__init__.py @@ -0,0 +1,34 @@ +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 = ["@candrews"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +tfluna_ns = cg.esphome_ns.namespace("tfluna") + +TFLunaComponent = tfluna_ns.class_("TFLuna", cg.PollingComponent, i2c.I2CDevice) + +CONF_TFLUNA_ID = "tfluna_id" +CONF_TIMESTAMP = "timestamp" + +FACTORY_DEFAULT_ADDRESS = 0x10 + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TFLunaComponent), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(FACTORY_DEFAULT_ADDRESS)) +) + + +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/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py new file mode 100644 index 00000000000..deb2ba8ffb3 --- /dev/null +++ b/esphome/components/tfluna/button/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FACTORY_RESET, + CONF_RESTART, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RESTART, + ICON_RESTART_ALERT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import CONF_TFLUNA_ID, FACTORY_DEFAULT_ADDRESS, TFLunaComponent, tfluna_ns + +DEPENDENCIES = ["tfluna"] + +ResetButton = tfluna_ns.class_("ResetButton", button.Button) +RestartButton = tfluna_ns.class_("RestartButton", button.Button) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_FACTORY_RESET): button.button_schema( + ResetButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ), + cv.Optional(CONF_RESTART): button.button_schema( + RestartButton, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ), +} + + +def _validate_factory_default_address(config: ConfigType) -> ConfigType: + if config.get(CONF_ADDRESS) != FACTORY_DEFAULT_ADDRESS: + raise cv.Invalid( + f"'{CONF_FACTORY_RESET}' requires the TF-Luna to use its factory default " + f"I2C address 0x{FACTORY_DEFAULT_ADDRESS:02X}, as a factory reset " + "restores that address", + path=[CONF_ADDRESS], + ) + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_FACTORY_RESET in config: + fv.id_declaration_match_schema(_validate_factory_default_address)( + config[CONF_TFLUNA_ID] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if factory_reset_config := config.get(CONF_FACTORY_RESET): + b = await button.new_button(factory_reset_config) + await cg.register_parented(b, config[CONF_TFLUNA_ID]) + cg.add(tfluna_component.set_reset_button(b)) + if restart_config := config.get(CONF_RESTART): + b = await button.new_button(restart_config) + await cg.register_parented(b, config[CONF_TFLUNA_ID]) + cg.add(tfluna_component.set_restart_button(b)) diff --git a/esphome/components/tfluna/button/reset_button.cpp b/esphome/components/tfluna/button/reset_button.cpp new file mode 100644 index 00000000000..b973f668642 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.cpp @@ -0,0 +1,7 @@ +#include "reset_button.h" + +namespace esphome::tfluna { + +void ResetButton::press_action() { this->parent_->factory_reset(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/reset_button.h b/esphome/components/tfluna/button/reset_button.h new file mode 100644 index 00000000000..a52f479eea0 --- /dev/null +++ b/esphome/components/tfluna/button/reset_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class ResetButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.cpp b/esphome/components/tfluna/button/restart_button.cpp new file mode 100644 index 00000000000..546be2742d8 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.cpp @@ -0,0 +1,7 @@ +#include "restart_button.h" + +namespace esphome::tfluna { + +void RestartButton::press_action() { this->parent_->restart(); } + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/button/restart_button.h b/esphome/components/tfluna/button/restart_button.h new file mode 100644 index 00000000000..7caaebe6b38 --- /dev/null +++ b/esphome/components/tfluna/button/restart_button.h @@ -0,0 +1,13 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../tfluna.h" + +namespace esphome::tfluna { + +class RestartButton : public button::Button, public Parented { + protected: + void press_action() override; +}; + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py new file mode 100644 index 00000000000..d101d12ae01 --- /dev/null +++ b/esphome/components/tfluna/sensor.py @@ -0,0 +1,75 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_ARROW_EXPAND_VERTICAL, + ICON_SIGNAL, + ICON_THERMOMETER, + ICON_TIMER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_CENTIMETER, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from . import CONF_TFLUNA_ID, CONF_TIMESTAMP, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_ARROW_EXPAND_VERTICAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + icon=ICON_SIGNAL, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIMESTAMP): sensor.sensor_schema( + icon=ICON_TIMER, + accuracy_decimals=0, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if distance_config := config.get(CONF_DISTANCE): + sens = await sensor.new_sensor(distance_config) + cg.add(tfluna_component.set_distance_sensor(sens)) + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(tfluna_component.set_temperature_sensor(sens)) + if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): + sens = await sensor.new_sensor(signal_strength_config) + cg.add(tfluna_component.set_signal_strength_sensor(sens)) + if timestamp_config := config.get(CONF_TIMESTAMP): + sens = await sensor.new_sensor(timestamp_config) + cg.add(tfluna_component.set_timestamp_sensor(sens)) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py new file mode 100644 index 00000000000..6bce14252b0 --- /dev/null +++ b/esphome/components/tfluna/text_sensor.py @@ -0,0 +1,23 @@ +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_TFLUNA_ID, TFLunaComponent + +DEPENDENCIES = ["tfluna"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) + if version_config := config.get(CONF_VERSION): + sens = await text_sensor.new_text_sensor(version_config) + cg.add(tfluna_component.set_version_text_sensor(sens)) diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp new file mode 100644 index 00000000000..1466ba89470 --- /dev/null +++ b/esphome/components/tfluna/tfluna.cpp @@ -0,0 +1,158 @@ +#include "tfluna.h" +#include +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::tfluna { + +// see https://files.waveshare.com/upload/a/ac/SJ-PM-TF-Luna_A05_Product_Manual.pdf +static constexpr uint8_t SAVE_REGISTER = 0x20; +static constexpr uint8_t VERSION_REVISION_REGISTER = 0x0A; +static constexpr uint8_t DISTANCE_LOW_REGISTER = 0x00; +static constexpr uint8_t RESTORE_FACTORY_DEFAULTS_REGISTER = 0x29; +static constexpr uint8_t SHUTDOWN_REBOOT_REGISTER = 0x21; +static constexpr uint8_t MODE_REGISTER = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; +static constexpr uint8_t MAX_READ_ATTEMPTS = 5; +static constexpr uint8_t READ_RETRY_MS = 5; +static const char *const TAG = "tfluna"; +static const char *const READ_RETRY_TIMEOUT = "read_retry"; + +void TFLuna::dump_config() { + ESP_LOGCONFIG(TAG, "TF-Luna (i2c):"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Firmware: %s", this->version_); + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Distance:", this->distance_sensor_); + LOG_SENSOR(" ", "Temperature:", this->temperature_sensor_); + LOG_SENSOR(" ", "Signal Strength:", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Timestamp:", this->timestamp_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version:", this->version_text_sensor_); +#endif +#ifdef USE_BUTTON + LOG_BUTTON(" ", "Factory Reset:", this->reset_button_); + LOG_BUTTON(" ", "Restart:", this->restart_button_); +#endif +} + +void TFLuna::setup() { + uint8_t mode; + if (!this->read_byte(MODE_REGISTER, &mode)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + if (mode != MODE_TRIGGER) { + if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + + if (!this->write_byte(SAVE_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(); + return; + } + } + + uint8_t raw_version[3]; + if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } else { + snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version_); + } +#endif + } +} + +bool TFLuna::read_data_() { + uint8_t buf[8]; + if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return false; + } + // Layout: + // buf[0..1] = distance (LE), buf[2..3] = signal (LE), + // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) + uint16_t distance = encode_uint16(buf[1], buf[0]); + uint16_t signal_strength = encode_uint16(buf[3], buf[2]); + int16_t temperature_raw = encode_uint16(buf[5], buf[4]); + uint16_t timestamp = encode_uint16(buf[7], buf[6]); + + if (timestamp == this->previous_timestamp_) { + return false; + } + +#ifdef USE_SENSOR + if (this->timestamp_sensor_ != nullptr) { + this->timestamp_sensor_->publish_state(timestamp); + } + if (this->distance_sensor_ != nullptr) { + // distance is unreliable when strength is below 100 or saturated at 0xFFFF + this->distance_sensor_->publish_state(signal_strength < 100 || signal_strength == 0xFFFF ? NAN : distance); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(temperature_raw / (float) 100); + } + + if (this->signal_strength_sensor_ != nullptr) { + this->signal_strength_sensor_->publish_state(signal_strength); + } +#endif + this->status_clear_warning(); + this->previous_timestamp_ = timestamp; + return true; +} + +void TFLuna::read_data_timeout_() { + if (this->read_data_()) { + this->attempt_ = 0; + } else { + if (this->attempt_ < MAX_READ_ATTEMPTS) { + this->attempt_++; + this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); + } else { + this->status_set_warning("Hung device, restarting..."); + this->restart(); + } + } +} + +void TFLuna::update() { + this->cancel_timeout(READ_RETRY_TIMEOUT); + this->attempt_ = 0; + if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + this->read_data_timeout_(); +} + +void TFLuna::factory_reset() { + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + this->status_set_warning("Factory reset issued; waiting for device to become ready"); + this->set_timeout("_setup", 100, [this]() { this->setup(); }); +} + +void TFLuna::restart() { + if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +} // namespace esphome::tfluna diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h new file mode 100644 index 00000000000..0cc866ef1df --- /dev/null +++ b/esphome/components/tfluna/tfluna.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_BUTTON +#include "esphome/components/button/button.h" +#endif +#include "esphome/components/i2c/i2c.h" + +namespace esphome::tfluna { + +class TFLuna : public i2c::I2CDevice, public PollingComponent { +#ifdef USE_SENSOR + SUB_SENSOR(distance) + SUB_SENSOR(temperature) + SUB_SENSOR(signal_strength) + SUB_SENSOR(timestamp) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) +#endif +#ifdef USE_BUTTON + SUB_BUTTON(reset) + SUB_BUTTON(restart) +#endif + public: + void setup() override; + + float get_setup_priority() const override { return setup_priority::LATE; } + + void dump_config() override; + + void update() override; + + void factory_reset(); + + void restart(); + + protected: + char version_[12] = "unknown"; + + uint16_t previous_timestamp_ = 0; + + [[nodiscard]] bool read_data_(); + + void read_data_timeout_(); + + uint8_t attempt_ = 0; +}; + +} // namespace esphome::tfluna diff --git a/tests/components/tfluna/common.yaml b/tests/components/tfluna/common.yaml new file mode 100644 index 00000000000..5804ae6981b --- /dev/null +++ b/tests/components/tfluna/common.yaml @@ -0,0 +1,24 @@ +tfluna: + +sensor: + - platform: tfluna + distance: + name: "Distance" + signal_strength: + name: TF-Luna Signal Strength + temperature: + name: TF-Luna Temperature + timestamp: + name: TF-Luna Timestamp + +text_sensor: + - platform: tfluna + version: + name: "TF-Luna firmware version" + +button: + - platform: tfluna + factory_reset: + name: "factory reset" + restart: + name: "restart" diff --git a/tests/components/tfluna/test.esp32-idf.yaml b/tests/components/tfluna/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/tfluna/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.esp8266-ard.yaml b/tests/components/tfluna/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/tfluna/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/tfluna/test.rp2040-ard.yaml b/tests/components/tfluna/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/tfluna/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From bfe1f7bd171fa83d046e02142dc9e6408908c00f Mon Sep 17 00:00:00 2001 From: Chris Nussbaum Date: Fri, 25 Sep 2026 18:03:14 -0500 Subject: [PATCH 533/759] [pzem6l24] Add PZEM-6L24 three-phase energy monitor sensor (#17849) 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 --- CODEOWNERS | 1 + esphome/components/pzem6l24/__init__.py | 0 esphome/components/pzem6l24/pzem6l24.cpp | 335 ++++++++++++++++++ esphome/components/pzem6l24/pzem6l24.h | 161 +++++++++ esphome/components/pzem6l24/sensor.py | 198 +++++++++++ tests/component_tests/pzem6l24/__init__.py | 0 .../component_tests/pzem6l24/test_address.py | 28 ++ tests/components/pzem6l24/common.h | 46 +++ tests/components/pzem6l24/common.yaml | 82 +++++ .../pzem6l24/sensor/pzem6l24_test.cpp | 315 ++++++++++++++++ tests/components/pzem6l24/test.esp32-idf.yaml | 8 + .../components/pzem6l24/test.esp8266-ard.yaml | 7 + .../components/pzem6l24/test.rp2040-ard.yaml | 7 + 13 files changed, 1188 insertions(+) create mode 100644 esphome/components/pzem6l24/__init__.py create mode 100644 esphome/components/pzem6l24/pzem6l24.cpp create mode 100644 esphome/components/pzem6l24/pzem6l24.h create mode 100644 esphome/components/pzem6l24/sensor.py create mode 100644 tests/component_tests/pzem6l24/__init__.py create mode 100644 tests/component_tests/pzem6l24/test_address.py create mode 100644 tests/components/pzem6l24/common.h create mode 100644 tests/components/pzem6l24/common.yaml create mode 100644 tests/components/pzem6l24/sensor/pzem6l24_test.cpp create mode 100644 tests/components/pzem6l24/test.esp32-idf.yaml create mode 100644 tests/components/pzem6l24/test.esp8266-ard.yaml create mode 100644 tests/components/pzem6l24/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c582728e76b..52fec0a7aca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -429,6 +429,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/pzem6l24/* @nuttytree esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje diff --git a/esphome/components/pzem6l24/__init__.py b/esphome/components/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 00000000000..f9f97606a82 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -0,0 +1,335 @@ +#include "pzem6l24.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::pzem6l24 { + +static const char *const TAG = "pzem6l24"; + +// ----------------------------------------------------------------------- +// Register map (input registers, starting address 0x0000): +// +// The PZEM-6L24 returns all register bytes in little-endian order, +// i.e. the low byte of each 16-bit register is transmitted first. +// 32-bit quantities occupy two consecutive registers with the low +// word at the lower address. +// +// NOTE: this is the opposite of standard Modbus, and of the single-phase +// pzemac component, which decodes big-endian. It is not an oversight: the +// byte order below was established against a live PZEM-6L24, so please do +// not "correct" it to big-endian without a device to verify against. +// +// Byte offset = register_address × 2 +// +// 0x0000 (byte 0) – Voltage A (uint16, ×0.1 V) +// 0x0001 (byte 2) – Voltage B (uint16, ×0.1 V) +// 0x0002 (byte 4) – Voltage C (uint16, ×0.1 V) +// 0x0003 (byte 6) – Current A (uint16, ×0.01 A) +// 0x0004 (byte 8) – Current B (uint16, ×0.01 A) +// 0x0005 (byte 10) – Current C (uint16, ×0.01 A) +// 0x0006 (byte 12) – Frequency A (uint16, ×0.01 Hz) +// 0x0007 (byte 14) – Frequency B (uint16, ×0.01 Hz) +// 0x0008 (byte 16) – Frequency C (uint16, ×0.01 Hz) +// 0x0009 (byte 18) – Voltage angle B (uint16, ×0.01 °) +// 0x000A (byte 20) – Voltage angle C (uint16, ×0.01 °) +// 0x000B (byte 22) – Current angle A (uint16, ×0.01 °) +// 0x000C (byte 24) – Current angle B (uint16, ×0.01 °) +// 0x000D (byte 26) – Current angle C (uint16, ×0.01 °) +// 0x000E (byte 28) – Active power A (int32 lo-word, ×0.1 W) +// 0x000F (byte 30) – Active power A (int32 hi-word) +// 0x0010 (byte 32) – Active power B (int32 lo-word, ×0.1 W) +// 0x0011 (byte 34) – Active power B (int32 hi-word) +// 0x0012 (byte 36) – Active power C (int32 lo-word, ×0.1 W) +// 0x0013 (byte 38) – Active power C (int32 hi-word) +// 0x0014 (byte 40) – Reactive power A (int32 lo-word, ×0.1 var) +// 0x0015 (byte 42) – Reactive power A (int32 hi-word) +// 0x0016 (byte 44) – Reactive power B (int32 lo-word, ×0.1 var) +// 0x0017 (byte 46) – Reactive power B (int32 hi-word) +// 0x0018 (byte 48) – Reactive power C (int32 lo-word, ×0.1 var) +// 0x0019 (byte 50) – Reactive power C (int32 hi-word) +// 0x001A (byte 52) – Apparent power A (int32 lo-word, ×0.1 VA) +// 0x001B (byte 54) – Apparent power A (int32 hi-word) +// 0x001C (byte 56) – Apparent power B (int32 lo-word, ×0.1 VA) +// 0x001D (byte 58) – Apparent power B (int32 hi-word) +// 0x001E (byte 60) – Apparent power C (int32 lo-word, ×0.1 VA) +// 0x001F (byte 62) – Apparent power C (int32 hi-word) +// 0x0020 (byte 64) – Total active pwr (int32 lo-word, ×0.1 W) +// 0x0021 (byte 66) – Total active pwr (int32 hi-word) +// 0x0022 (byte 68) – Total reactive pwr (int32 lo-word, ×0.1 var) +// 0x0023 (byte 70) – Total reactive pwr (int32 hi-word) +// 0x0024 (byte 72) – Total apparent pwr (int32 lo-word, ×0.1 VA) +// 0x0025 (byte 74) – Total apparent pwr (int32 hi-word) +// 0x0026 (byte 76) – Power factor A/B: hi-byte = A (×0.01), lo-byte = B (×0.01) +// 0x0027 (byte 78) – Power factor C/tot:hi-byte = C (×0.01), lo-byte = total (×0.01) +// 0x0028 (byte 80) – Active energy A (uint32 lo-word, ×0.1 kWh) +// 0x0029 (byte 82) – Active energy A (uint32 hi-word) +// 0x002A (byte 84) – Active energy B (uint32 lo-word, ×0.1 kWh) +// 0x002B (byte 86) – Active energy B (uint32 hi-word) +// 0x002C (byte 88) – Active energy C (uint32 lo-word, ×0.1 kWh) +// 0x002D (byte 90) – Active energy C (uint32 hi-word) +// 0x002E (byte 92) – Reactive energy A (uint32 lo-word, ×0.1 kvarh) +// 0x002F (byte 94) – Reactive energy A (uint32 hi-word) +// 0x0030 (byte 96) – Reactive energy B (uint32 lo-word, ×0.1 kvarh) +// 0x0031 (byte 98) – Reactive energy B (uint32 hi-word) +// 0x0032 (byte 100)– Reactive energy C (uint32 lo-word, ×0.1 kvarh) +// 0x0033 (byte 102)– Reactive energy C (uint32 hi-word) +// 0x0034 (byte 104)– Apparent energy A (uint32 lo-word, ×0.1 kVAh) +// 0x0035 (byte 106)– Apparent energy A (uint32 hi-word) +// 0x0036 (byte 108)– Apparent energy B (uint32 lo-word, ×0.1 kVAh) +// 0x0037 (byte 110)– Apparent energy B (uint32 hi-word) +// 0x0038 (byte 112)– Apparent energy C (uint32 lo-word, ×0.1 kVAh) +// 0x0039 (byte 114)– Apparent energy C (uint32 hi-word) +// 0x003A (byte 116)– Total active nrg (uint32 lo-word, ×0.1 kWh) +// 0x003B (byte 118)– Total active nrg (uint32 hi-word) +// 0x003C (byte 120)– Total reactive nrg (uint32 lo-word, ×0.1 kvarh) +// 0x003D (byte 122)– Total reactive nrg (uint32 hi-word) +// 0x003E (byte 124)– Total apparent nrg (uint32 lo-word, ×0.1 kVAh) +// 0x003F (byte 126)– Total apparent nrg (uint32 hi-word) +// ----------------------------------------------------------------------- + +// Width of a quantity in the register map above. +enum RegType : uint8_t { + REG_U8, // single byte (the packed power factors) + REG_U16, // one register, unsigned + REG_U32, // two registers, unsigned, low word first + REG_I32, // two registers, signed, low word first +}; + +// Scale factor of a quantity; the table has only these two, so a byte replaces a float per entry. +enum Scale : uint8_t { + SCALE_DECI, // ×0.1 + SCALE_CENTI, // ×0.01 +}; + +// One decodable quantity: where it lives in the payload, how to read it and which sensor it feeds. +// Copied out of flash with memcpy, so it must stay trivially copyable. +struct SensorEntry { + sensor::Sensor *PZEM6L24::*member; + uint8_t offset; + RegType type; + Scale scale; +}; +static_assert(std::is_trivially_copyable_v, "SENSORS is copied out of flash with memcpy"); + +// True for the periodic register read issued by update(); the only other request is the 0x42 reset. +static bool is_register_read(std::span request_pdu) { + return modbus::helpers::pdu_function_code(request_pdu) == + static_cast(modbus::FunctionCode::READ_INPUT_REGISTERS); +} + +void PZEM6L24::on_response(std::span request_pdu, std::span response_pdu) { + if (!is_register_read(request_pdu)) { + return; + } + this->read_finished_(); + const auto data = modbus::helpers::server_pdu_payload(response_pdu); + // Anything but exactly PZEM_PAYLOAD_SIZE bytes cannot be decoded by the table. + if (data.size() != PZEM_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Invalid data size for PZEM-6L24: expected %zu bytes, got %zu", PZEM_PAYLOAD_SIZE, data.size()); + this->read_failed_(); + return; + } + this->consecutive_failures_ = 0; + this->publish_(data.data()); +} + +void PZEM6L24::on_error(std::span request_pdu, modbus::ExceptionCode) { + this->request_failed_(request_pdu); +} + +bool PZEM6L24::on_no_response(std::span request_pdu) { + this->request_failed_(request_pdu); + return false; // no retry; the next update() polls again. +} + +void PZEM6L24::on_not_sent(std::span request_pdu) { this->request_failed_(request_pdu); } + +// A register read that produced no measurements counts toward blanking; a failed energy reset does +// not, but the user is told. The hub has already logged the cause. +void PZEM6L24::request_failed_(std::span request_pdu) { + if (is_register_read(request_pdu)) { + this->read_finished_(); + this->read_failed_(); + } else { + ESP_LOGW(TAG, "Energy reset failed; the counters were not cleared"); + } +} + +// `data` points at PZEM_PAYLOAD_SIZE validated bytes, or is nullptr to blank every sensor; both walk +// the same table. +void PZEM6L24::publish_(const uint8_t *data) { + const bool available = data != nullptr; + + // Byte offset, width and scale for every quantity, in register-map order. All three phases share the + // same grid frequency, so phase A's register is reported. + // + // PROGMEM: on ESP8266 .rodata is DRAM, so the table would otherwise cost ~280 bytes of RAM; the price + // is one 8-byte copy per sensor per poll. No name column for the same reason; dump_config() names + // every sensor from flash. + static constexpr SensorEntry SENSORS[] PROGMEM = { + // Voltages (×0.1 V) + {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + // Currents (×0.01 A) + {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + // Frequency (×0.01 Hz) + {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + // Active powers (×0.1 W, signed) + {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + // Reactive powers (×0.1 var, signed) + {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + // Apparent powers (×0.1 VA, signed) + {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + // Power factors (×0.01), packed two per register: + // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A + // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C + {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + // Active energies (×0.1 kWh, unsigned) + {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + // Reactive energies (×0.1 kvarh, unsigned) + {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + // Apparent energies (×0.1 kVAh, unsigned) + {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + }; + + for (const SensorEntry &flash_entry : SENSORS) { + SensorEntry entry; + progmem_memcpy(&entry, &flash_entry, sizeof(entry)); + sensor::Sensor *sens = this->*entry.member; + if (sens == nullptr) + continue; + if (!available) { + sens->publish_state(NAN); + continue; + } + // No default: an added RegType must fail to compile. The wire is little-endian, hence the reversed + // byte arguments. + const size_t o = entry.offset; + float raw = 0.0f; + switch (entry.type) { + case REG_U8: + raw = data[o]; + break; + case REG_U16: + raw = encode_uint16(data[o + 1], data[o]); + break; + case REG_U32: + raw = encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o]); + break; + case REG_I32: + raw = static_cast(encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o])); + break; + } + sens->publish_state(raw * (entry.scale == SCALE_CENTI ? 0.01f : 0.1f)); + } +} + +void PZEM6L24::update() { + if (this->read_input_registers(0x0000, PZEM_REGISTER_COUNT)) { + this->reads_outstanding_++; + } else if (this->reads_outstanding_ == 0) { + // Refused with nothing in flight: no callback is coming, and the hub has logged why. A refusal + // while a read is outstanding is a duplicate of it, which still resolves in that read's callback. + this->read_failed_(); + } +} + +void PZEM6L24::dump_config() { + ESP_LOGCONFIG(TAG, + "PZEM-6L24:\n" + " Address: 0x%02X", + this->address_); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_); + LOG_SENSOR(" ", "Current A", this->current_a_); + LOG_SENSOR(" ", "Current B", this->current_b_); + LOG_SENSOR(" ", "Current C", this->current_c_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); + LOG_SENSOR(" ", "Frequency", this->frequency_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); +} + +// Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. +void PZEM6L24::read_failed_() { + if (this->consecutive_failures_ >= MAX_CONSECUTIVE_READ_FAILURES) { + return; + } + if (++this->consecutive_failures_ == MAX_CONSECUTIVE_READ_FAILURES) { + ESP_LOGW(TAG, "No valid reading in %u consecutive polls; the readings are now unavailable", + MAX_CONSECUTIVE_READ_FAILURES); + this->publish_(nullptr); + } +} + +// One terminal has arrived for a register read, so that read is no longer in flight. +void PZEM6L24::read_finished_() { + if (this->reads_outstanding_ > 0) { + this->reads_outstanding_--; + } +} + +void PZEM6L24::reset_energy(ResetPhase phase_option) { + const auto pdu = build_reset_pdu(phase_option); + // A refused request gets no callback, so report it here. + if (!this->queue_pdu(pdu)) { + this->request_failed_(pdu); + } +} + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h new file mode 100644 index 00000000000..bf13f25053e --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -0,0 +1,161 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/modbus/modbus.h" + +#include +#include + +namespace esphome::pzem6l24 { + +// Options for the reset_energy action +enum ResetPhase : uint8_t { + RESET_PHASE_A = 0x00, // Reset phase A energy only + RESET_PHASE_B = 0x01, // Reset phase B energy only + RESET_PHASE_C = 0x02, // Reset phase C energy only + RESET_PHASE_COMBINED = 0x03, // Reset combined (sum) energy only + RESET_PHASE_ALL = 0x0F, // Reset all energy counters +}; + +// Reset energy function code (PZEM-6L24 specific, non-standard Modbus) +static constexpr uint8_t PZEM_CMD_RESET_ENERGY = 0x42; + +// Input registers read per poll (0x0000 - 0x003F inclusive) and the payload size that yields. +static constexpr uint8_t PZEM_REGISTER_COUNT = 64; +static constexpr size_t PZEM_PAYLOAD_SIZE = PZEM_REGISTER_COUNT * 2; + +// Failed polls tolerated before the readings are blanked; one collision on a shared RS-485 bus should +// not take every entity unavailable for a whole update interval. +static constexpr uint8_t MAX_CONSECUTIVE_READ_FAILURES = 3; + +// The energy reset command as it goes on the wire: function code, reserved byte, phase selector. The +// hub adds the address and CRC. Split out so the phase byte can be pinned by a test. +constexpr std::array build_reset_pdu(ResetPhase phase) { + return {PZEM_CMD_RESET_ENERGY, 0x00, static_cast(phase)}; +} + +class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + public: + // Per-phase sensors; each setter is named after its config key + void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } + void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } + void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } + + void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } + void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } + void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } + + void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } + void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } + void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } + + void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } + void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } + void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } + + void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } + void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } + void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } + + void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } + void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } + void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } + + void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } + void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } + void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } + + void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } + void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } + void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } + + void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } + void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } + void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } + + // Combined sensors + void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } + void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } + void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } + void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } + void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } + void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } + void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } + void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + + // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. + void reset_energy(ResetPhase phase_option); + + void update() override; + + void on_response(std::span request_pdu, std::span response_pdu) override; + + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + + bool on_no_response(std::span request_pdu) override; + + void on_not_sent(std::span request_pdu) override; + + void dump_config() override; + + protected: + void publish_(const uint8_t *data); + void request_failed_(std::span request_pdu); + void read_failed_(); + void read_finished_(); + + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + + // Per-phase sensors + sensor::Sensor *voltage_a_{nullptr}; + sensor::Sensor *voltage_b_{nullptr}; + sensor::Sensor *voltage_c_{nullptr}; + + sensor::Sensor *current_a_{nullptr}; + sensor::Sensor *current_b_{nullptr}; + sensor::Sensor *current_c_{nullptr}; + + sensor::Sensor *active_power_a_{nullptr}; + sensor::Sensor *active_power_b_{nullptr}; + sensor::Sensor *active_power_c_{nullptr}; + + sensor::Sensor *reactive_power_a_{nullptr}; + sensor::Sensor *reactive_power_b_{nullptr}; + sensor::Sensor *reactive_power_c_{nullptr}; + + sensor::Sensor *apparent_power_a_{nullptr}; + sensor::Sensor *apparent_power_b_{nullptr}; + sensor::Sensor *apparent_power_c_{nullptr}; + + sensor::Sensor *power_factor_a_{nullptr}; + sensor::Sensor *power_factor_b_{nullptr}; + sensor::Sensor *power_factor_c_{nullptr}; + + sensor::Sensor *active_energy_a_{nullptr}; + sensor::Sensor *active_energy_b_{nullptr}; + sensor::Sensor *active_energy_c_{nullptr}; + + sensor::Sensor *reactive_energy_a_{nullptr}; + sensor::Sensor *reactive_energy_b_{nullptr}; + sensor::Sensor *reactive_energy_c_{nullptr}; + + sensor::Sensor *apparent_energy_a_{nullptr}; + sensor::Sensor *apparent_energy_b_{nullptr}; + sensor::Sensor *apparent_energy_c_{nullptr}; + + // Combined sensors + sensor::Sensor *frequency_{nullptr}; + sensor::Sensor *total_active_power_{nullptr}; + sensor::Sensor *total_reactive_power_{nullptr}; + sensor::Sensor *total_apparent_power_{nullptr}; + sensor::Sensor *total_power_factor_{nullptr}; + sensor::Sensor *total_active_energy_{nullptr}; + sensor::Sensor *total_reactive_energy_{nullptr}; + sensor::Sensor *total_apparent_energy_{nullptr}; +}; + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py new file mode 100644 index 00000000000..fd0430d85be --- /dev/null +++ b/esphome/components/pzem6l24/sensor.py @@ -0,0 +1,198 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import modbus, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FREQUENCY, + CONF_ID, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_REACTIVE_POWER, + DEVICE_CLASS_VOLTAGE, + ICON_CURRENT_AC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE, + UNIT_WATT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["modbus"] +CODEOWNERS = ["@nuttytree"] + +pzem6l24_ns = cg.esphome_ns.namespace("pzem6l24") +PZEM6L24 = pzem6l24_ns.class_( + "PZEM6L24", cg.PollingComponent, modbus.ModbusClientDevice +) + +ResetPhase = pzem6l24_ns.enum("ResetPhase") +RESET_PHASE_OPTIONS = { + "all": ResetPhase.RESET_PHASE_ALL, + "a": ResetPhase.RESET_PHASE_A, + "b": ResetPhase.RESET_PHASE_B, + "c": ResetPhase.RESET_PHASE_C, + "combined": ResetPhase.RESET_PHASE_COMBINED, +} + +# Combined config keys +CONF_TOTAL_ACTIVE_POWER = "total_active_power" +CONF_TOTAL_REACTIVE_POWER = "total_reactive_power" +CONF_TOTAL_APPARENT_POWER = "total_apparent_power" +CONF_TOTAL_POWER_FACTOR = "total_power_factor" +CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" +CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" +CONF_TOTAL_APPARENT_ENERGY = "total_apparent_energy" +CONF_PHASE = "phase" + + +_VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +_CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_REACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_APPARENT_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_POWER_FACTOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_REACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_APPARENT_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + icon=ICON_CURRENT_AC, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Quantities measured once per phase; the config key is "_". +_PHASE_QUANTITIES = { + "voltage": _VOLTAGE_SCHEMA, + "current": _CURRENT_SCHEMA, + "active_power": _ACTIVE_POWER_SCHEMA, + "reactive_power": _REACTIVE_POWER_SCHEMA, + "apparent_power": _APPARENT_POWER_SCHEMA, + "power_factor": _POWER_FACTOR_SCHEMA, + "active_energy": _ACTIVE_ENERGY_SCHEMA, + "reactive_energy": _REACTIVE_ENERGY_SCHEMA, + "apparent_energy": _APPARENT_ENERGY_SCHEMA, +} + +# Quantities the meter reports once, keyed by config key. +_COMBINED_QUANTITIES = { + CONF_FREQUENCY: _FREQUENCY_SCHEMA, + CONF_TOTAL_ACTIVE_POWER: _ACTIVE_POWER_SCHEMA, + CONF_TOTAL_REACTIVE_POWER: _REACTIVE_POWER_SCHEMA, + CONF_TOTAL_APPARENT_POWER: _APPARENT_POWER_SCHEMA, + CONF_TOTAL_POWER_FACTOR: _POWER_FACTOR_SCHEMA, + CONF_TOTAL_ACTIVE_ENERGY: _ACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_REACTIVE_ENERGY: _REACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, +} + +# Every sensor by config key; the C++ setter is "set__sensor". +_SENSORS = { + f"{name}_{phase}": schema + for name, schema in _PHASE_QUANTITIES.items() + for phase in "abc" +} | _COMBINED_QUANTITIES + +# The meter answers unit addresses 1 to 247; 0 is the Modbus broadcast address and 248 to 255 are reserved. +_ADDRESS_SCHEMA = cv.All( + cv.hex_uint8_t, + cv.Range(min=1, max=247, msg="The PZEM-6L24 answers unit addresses 1 to 247 only"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PZEM6L24), + **{cv.Optional(key): schema for key, schema in _SENSORS.items()}, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(modbus.modbus_device_schema(0x01)) + .extend({cv.Optional(CONF_ADDRESS, default=0x01): _ADDRESS_SCHEMA}) +) + + +automation.register_apply_action( + "pzem6l24.reset_energy", + maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PZEM6L24), + cv.Optional(CONF_PHASE, default="all"): cv.enum( + RESET_PHASE_OPTIONS, lower=True + ), + } + ), + automation.ApplyField(CONF_PHASE, "reset_energy", ResetPhase), +) + + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="client") + + +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) + + for key in _SENSORS: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) diff --git a/tests/component_tests/pzem6l24/__init__.py b/tests/component_tests/pzem6l24/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/pzem6l24/test_address.py b/tests/component_tests/pzem6l24/test_address.py new file mode 100644 index 00000000000..a9e14361c0d --- /dev/null +++ b/tests/component_tests/pzem6l24/test_address.py @@ -0,0 +1,28 @@ +"""The PZEM-6L24 answers unit addresses 1 to 247 only.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.pzem6l24.sensor import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _sensor(**extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", **extra}) + + +def test_address_defaults_to_one() -> None: + assert _sensor()[CONF_ADDRESS] == 1 + + +@pytest.mark.parametrize("address", [1, 247]) +def test_address_in_unit_range_accepted(address: int) -> None: + assert _sensor(**{CONF_ADDRESS: address})[CONF_ADDRESS] == address + + +@pytest.mark.parametrize("address", [0, 248, 255]) +def test_address_outside_unit_range_rejected(address: int) -> None: + with pytest.raises(cv.Invalid): + _sensor(**{CONF_ADDRESS: address}) diff --git a/tests/components/pzem6l24/common.h b/tests/components/pzem6l24/common.h new file mode 100644 index 00000000000..5ceab795179 --- /dev/null +++ b/tests/components/pzem6l24/common.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/pzem6l24/pzem6l24.h" + +namespace esphome::pzem6l24::testing { + +// The request PDU update() puts on the wire: read 64 input registers from 0x0000. +static constexpr uint8_t READ_REQUEST_PDU[] = {0x04, 0x00, 0x00, 0x00, PZEM_REGISTER_COUNT}; +// The request PDU reset_energy() puts on the wire for every phase. +static constexpr auto RESET_REQUEST_PDU = build_reset_pdu(RESET_PHASE_ALL); + +// Builds the 128-byte register payload the meter returns, writing each quantity in the +// little-endian byte order documented in pzem6l24.cpp's register map. +class PayloadBuilder { + public: + PayloadBuilder &u8(size_t offset, uint8_t value) { + this->data_[offset] = value; + return *this; + } + PayloadBuilder &u16(size_t offset, uint16_t value) { + this->data_[offset] = static_cast(value & 0xFF); + this->data_[offset + 1] = static_cast(value >> 8); + return *this; + } + PayloadBuilder &u32(size_t offset, uint32_t value) { + this->u16(offset, static_cast(value & 0xFFFF)); + this->u16(offset + 2, static_cast(value >> 16)); + return *this; + } + PayloadBuilder &i32(size_t offset, int32_t value) { return this->u32(offset, static_cast(value)); } + + // Wraps the payload in a read-input-registers response PDU: function code, byte count, data. + std::vector response_pdu() const { + std::vector pdu{0x04, static_cast(PZEM_PAYLOAD_SIZE)}; + pdu.insert(pdu.end(), this->data_.begin(), this->data_.end()); + return pdu; + } + + protected: + std::array data_{}; +}; + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/common.yaml b/tests/components/pzem6l24/common.yaml new file mode 100644 index 00000000000..25cb86cc84f --- /dev/null +++ b/tests/components/pzem6l24/common.yaml @@ -0,0 +1,82 @@ +esphome: + on_boot: + then: + - pzem6l24.reset_energy: pzem6l24_1 + - pzem6l24.reset_energy: + id: pzem6l24_1 + phase: b + +sensor: + - platform: pzem6l24 + modbus_id: modbus_bus + id: pzem6l24_1 + voltage_a: + name: PZEM6L24 Voltage A + voltage_b: + name: PZEM6L24 Voltage B + voltage_c: + name: PZEM6L24 Voltage C + current_a: + name: PZEM6L24 Current A + current_b: + name: PZEM6L24 Current B + current_c: + name: PZEM6L24 Current C + active_power_a: + name: PZEM6L24 Active Power A + active_power_b: + name: PZEM6L24 Active Power B + active_power_c: + name: PZEM6L24 Active Power C + reactive_power_a: + name: PZEM6L24 Reactive Power A + reactive_power_b: + name: PZEM6L24 Reactive Power B + reactive_power_c: + name: PZEM6L24 Reactive Power C + apparent_power_a: + name: PZEM6L24 Apparent Power A + apparent_power_b: + name: PZEM6L24 Apparent Power B + apparent_power_c: + name: PZEM6L24 Apparent Power C + power_factor_a: + name: PZEM6L24 Power Factor A + power_factor_b: + name: PZEM6L24 Power Factor B + power_factor_c: + name: PZEM6L24 Power Factor C + active_energy_a: + name: PZEM6L24 Active Energy A + active_energy_b: + name: PZEM6L24 Active Energy B + active_energy_c: + name: PZEM6L24 Active Energy C + reactive_energy_a: + name: PZEM6L24 Reactive Energy A + reactive_energy_b: + name: PZEM6L24 Reactive Energy B + reactive_energy_c: + name: PZEM6L24 Reactive Energy C + apparent_energy_a: + name: PZEM6L24 Apparent Energy A + apparent_energy_b: + name: PZEM6L24 Apparent Energy B + apparent_energy_c: + name: PZEM6L24 Apparent Energy C + frequency: + name: PZEM6L24 Frequency + total_active_power: + name: PZEM6L24 Total Active Power + total_reactive_power: + name: PZEM6L24 Total Reactive Power + total_apparent_power: + name: PZEM6L24 Total Apparent Power + total_power_factor: + name: PZEM6L24 Total Power Factor + total_active_energy: + name: PZEM6L24 Total Active Energy + total_reactive_energy: + name: PZEM6L24 Total Reactive Energy + total_apparent_energy: + name: PZEM6L24 Total Apparent Energy diff --git a/tests/components/pzem6l24/sensor/pzem6l24_test.cpp b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp new file mode 100644 index 00000000000..6dbb4ebf02a --- /dev/null +++ b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp @@ -0,0 +1,315 @@ +#include "../common.h" + +#include +#include +#include + +namespace esphome::pzem6l24::testing { + +namespace { + +// A payload whose raw values are distinct per quantity, so a transposed offset shows up as a wrong +// value rather than a coincidental match. Registers the component does not read are filled with 0xEE. +PayloadBuilder make_reference_payload() { + PayloadBuilder p; + // Voltages (×0.1 V) + p.u16(0, 2301).u16(2, 2302).u16(4, 2303); + // Currents (×0.01 A) + p.u16(6, 1234).u16(8, 1235).u16(10, 1236); + // Frequency phase A (×0.01 Hz); phases B/C and the angle registers are not read. + p.u16(12, 5001); + for (size_t offset = 14; offset < 28; offset++) { + p.u8(offset, 0xEE); + } + // Active power (×0.1 W, signed); phase C exercises the sign extension. + p.i32(28, 15001).i32(32, 15002).i32(36, -15003).i32(64, 45006); + // Reactive power (×0.1 var, signed) + p.i32(40, 2001).i32(44, 2002).i32(48, 2003).i32(68, 6006); + // Apparent power (×0.1 VA, signed) + p.i32(52, 3001).i32(56, 3002).i32(60, 3003).i32(72, 9006); + // Power factors (×0.01), packed two per register: 77=A, 76=B, 79=C, 78=combined + p.u8(77, 98).u8(76, 97).u8(79, 96).u8(78, 95); + // Active energy (×0.1 kWh) + p.u32(80, 100001).u32(84, 100002).u32(88, 100003).u32(116, 300006); + // Reactive energy (×0.1 kvarh) + p.u32(92, 200001).u32(96, 200002).u32(100, 200003).u32(120, 600006); + // Apparent energy (×0.1 kVAh); phase A exceeds 16 bits to exercise the high word. + p.u32(104, 300001).u32(108, 300002).u32(112, 300003).u32(124, 900006); + return p; +} + +// One PZEM6L24 with every sensor it can drive attached, so a test can assert that each byte offset in +// the register map reaches the sensor it is documented to feed. +struct Harness { + PZEM6L24 pzem; + + sensor::Sensor voltage_a, voltage_b, voltage_c; + sensor::Sensor current_a, current_b, current_c; + sensor::Sensor active_power_a, active_power_b, active_power_c; + sensor::Sensor reactive_power_a, reactive_power_b, reactive_power_c; + sensor::Sensor apparent_power_a, apparent_power_b, apparent_power_c; + sensor::Sensor power_factor_a, power_factor_b, power_factor_c; + sensor::Sensor active_energy_a, active_energy_b, active_energy_c; + sensor::Sensor reactive_energy_a, reactive_energy_b, reactive_energy_c; + sensor::Sensor apparent_energy_a, apparent_energy_b, apparent_energy_c; + sensor::Sensor frequency; + sensor::Sensor total_active_power, total_reactive_power, total_apparent_power; + sensor::Sensor total_power_factor; + sensor::Sensor total_active_energy, total_reactive_energy, total_apparent_energy; + + Harness() { + this->pzem.set_voltage_a_sensor(&this->voltage_a); + this->pzem.set_voltage_b_sensor(&this->voltage_b); + this->pzem.set_voltage_c_sensor(&this->voltage_c); + this->pzem.set_current_a_sensor(&this->current_a); + this->pzem.set_current_b_sensor(&this->current_b); + this->pzem.set_current_c_sensor(&this->current_c); + this->pzem.set_active_power_a_sensor(&this->active_power_a); + this->pzem.set_active_power_b_sensor(&this->active_power_b); + this->pzem.set_active_power_c_sensor(&this->active_power_c); + this->pzem.set_reactive_power_a_sensor(&this->reactive_power_a); + this->pzem.set_reactive_power_b_sensor(&this->reactive_power_b); + this->pzem.set_reactive_power_c_sensor(&this->reactive_power_c); + this->pzem.set_apparent_power_a_sensor(&this->apparent_power_a); + this->pzem.set_apparent_power_b_sensor(&this->apparent_power_b); + this->pzem.set_apparent_power_c_sensor(&this->apparent_power_c); + this->pzem.set_power_factor_a_sensor(&this->power_factor_a); + this->pzem.set_power_factor_b_sensor(&this->power_factor_b); + this->pzem.set_power_factor_c_sensor(&this->power_factor_c); + this->pzem.set_active_energy_a_sensor(&this->active_energy_a); + this->pzem.set_active_energy_b_sensor(&this->active_energy_b); + this->pzem.set_active_energy_c_sensor(&this->active_energy_c); + this->pzem.set_reactive_energy_a_sensor(&this->reactive_energy_a); + this->pzem.set_reactive_energy_b_sensor(&this->reactive_energy_b); + this->pzem.set_reactive_energy_c_sensor(&this->reactive_energy_c); + this->pzem.set_apparent_energy_a_sensor(&this->apparent_energy_a); + this->pzem.set_apparent_energy_b_sensor(&this->apparent_energy_b); + this->pzem.set_apparent_energy_c_sensor(&this->apparent_energy_c); + this->pzem.set_frequency_sensor(&this->frequency); + this->pzem.set_total_active_power_sensor(&this->total_active_power); + this->pzem.set_total_reactive_power_sensor(&this->total_reactive_power); + this->pzem.set_total_apparent_power_sensor(&this->total_apparent_power); + this->pzem.set_total_power_factor_sensor(&this->total_power_factor); + this->pzem.set_total_active_energy_sensor(&this->total_active_energy); + this->pzem.set_total_reactive_energy_sensor(&this->total_reactive_energy); + this->pzem.set_total_apparent_energy_sensor(&this->total_apparent_energy); + } + + // A good poll, as the hub would deliver it. + void poll_ok() { this->pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); } +}; + +// A good poll followed by MAX_CONSECUTIVE_READ_FAILURES calls of `fail` must blank every reading. +template void expect_blanked_after_repeated(Harness &h, F &&fail) { + h.poll_ok(); + ASSERT_FALSE(std::isnan(h.voltage_a.state)); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + fail(); + } + EXPECT_TRUE(std::isnan(h.voltage_a.state)); + EXPECT_TRUE(std::isnan(h.total_active_energy.state)); +} + +} // namespace + +TEST(PZEM6L24Test, DecodesEveryRegisterToItsSensor) { + Harness h; + const auto response = make_reference_payload().response_pdu(); + + h.pzem.on_response(READ_REQUEST_PDU, response); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_b.state, 2302 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_c.state, 2303 * 0.1f); + + EXPECT_FLOAT_EQ(h.current_a.state, 1234 * 0.01f); + EXPECT_FLOAT_EQ(h.current_b.state, 1235 * 0.01f); + EXPECT_FLOAT_EQ(h.current_c.state, 1236 * 0.01f); + + EXPECT_FLOAT_EQ(h.frequency.state, 5001 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_power_a.state, 15001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_b.state, 15002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_c.state, -15003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_power.state, 45006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_power_a.state, 2001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_b.state, 2002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_c.state, 2003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_power.state, 6006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_power_a.state, 3001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_b.state, 3002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_c.state, 3003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_power.state, 9006 * 0.1f); + + EXPECT_FLOAT_EQ(h.power_factor_a.state, 98 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_b.state, 97 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_c.state, 96 * 0.01f); + EXPECT_FLOAT_EQ(h.total_power_factor.state, 95 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_energy_a.state, 100001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_b.state, 100002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_c.state, 100003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_energy_a.state, 200001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_b.state, 200002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_c.state, 200003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_energy.state, 600006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_energy_a.state, 300001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_b.state, 300002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_c.state, 300003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_energy.state, 900006 * 0.1f); +} + +// Unconfigured sensors must be skipped rather than dereferenced. +TEST(PZEM6L24Test, PublishesOnlyConfiguredSensors) { + PZEM6L24 pzem; + sensor::Sensor voltage_a; + pzem.set_voltage_a_sensor(&voltage_a); + + pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); + + EXPECT_TRUE(voltage_a.has_state()); + EXPECT_FLOAT_EQ(voltage_a.state, 2301 * 0.1f); +} + +// The acknowledgement of the 0x42 reset command carries no measurements and must not be decoded. +TEST(PZEM6L24Test, IgnoresResetAcknowledgement) { + Harness h; + const uint8_t ack_pdu[] = {0x42, 0x00, 0x0F}; + + h.pzem.on_response(RESET_REQUEST_PDU, ack_pdu); + + EXPECT_FALSE(h.voltage_a.has_state()); + EXPECT_FALSE(h.total_active_energy.has_state()); +} + +// A truncated response must be rejected rather than decoded from out-of-range bytes. +TEST(PZEM6L24Test, PublishesNanOnShortPayload) { + Harness h; + std::vector short_pdu{0x04, 10}; + short_pdu.resize(12, 0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, short_pdu); }); +} + +// A byte-count-0 reply the hub still dispatches is as undecodable as any other wrong size. +TEST(PZEM6L24Test, PublishesNanOnEmptyPayload) { + Harness h; + const uint8_t empty_pdu[] = {0x04, 0x00}; + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, empty_pdu); }); +} + +// A response longer than the register map did not come from the expected frame layout. +TEST(PZEM6L24Test, PublishesNanOnOversizedPayload) { + Harness h; + auto long_pdu = make_reference_payload().response_pdu(); + long_pdu.push_back(0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, long_pdu); }); +} + +TEST(PZEM6L24Test, PublishesNanWhenTheMeterDoesNotRespond) { + Harness h; + expect_blanked_after_repeated(h, [&] { EXPECT_FALSE(h.pzem.on_no_response(READ_REQUEST_PDU)); }); +} + +TEST(PZEM6L24Test, PublishesNanOnExceptionResponse) { + Harness h; + expect_blanked_after_repeated( + h, [&] { h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); }); +} + +// A read dropped from the transmit queue never reaches the meter. +TEST(PZEM6L24Test, PublishesNanWhenTheReadIsNotSent) { + Harness h; + expect_blanked_after_repeated(h, [&] { h.pzem.on_not_sent(READ_REQUEST_PDU); }); +} + +// Readings ride out isolated failures, and a good poll restarts the count. +TEST(PZEM6L24Test, KeepsReadingsUntilFailuresReachTheThreshold) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_no_response(READ_REQUEST_PDU); + } + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + + h.poll_ok(); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::SERVICE_DEVICE_FAILURE); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); +} + +// update()'s refusal branch is pinned against a real hub in both directions. +TEST(PZEM6L24Test, KeepsReadingsWhenAPollIsAbsorbedIntoAReadInFlight) { + // Declared before the harness so it outlives it: ~ModbusClientDevice clears its frames from the hub. + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + h.poll_ok(); + + // A read entry serves at most two requests; the third poll is refused while two callbacks are owed. + h.pzem.update(); + h.pzem.update(); + h.pzem.update(); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// A refusal with nothing in flight means no callback is coming, so it counts as a failed poll. +TEST(PZEM6L24Test, PublishesNanWhenThePollCannotBeQueued) { + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + + // Fill the transmit queue with frames for another address, so the poll is refused rather than absorbed. + for (uint16_t i = 0; i < modbus::MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t filler_pdu[] = {0x04, 0x00, static_cast(i), 0x00, 0x01}; + ASSERT_TRUE(hub.queue_pdu(0x02, filler_pdu)); + } + + expect_blanked_after_repeated(h, [&] { h.pzem.update(); }); +} + +// A failed reset command says nothing about the measurements, so it must not blank them. +TEST(PZEM6L24Test, KeepsReadingsWhenTheResetCommandFails) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + EXPECT_FALSE(h.pzem.on_no_response(RESET_REQUEST_PDU)); + h.pzem.on_error(RESET_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_FUNCTION); + h.pzem.on_not_sent(RESET_REQUEST_PDU); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// The reset is irreversible, so the phase selector byte is pinned here. +TEST(PZEM6L24Test, BuildsTheResetFrameForEveryPhase) { + const std::array, 5> cases{{ + {RESET_PHASE_A, 0x00}, + {RESET_PHASE_B, 0x01}, + {RESET_PHASE_C, 0x02}, + {RESET_PHASE_COMBINED, 0x03}, + {RESET_PHASE_ALL, 0x0F}, + }}; + + for (const auto &[phase, selector] : cases) { + const auto pdu = build_reset_pdu(phase); + EXPECT_EQ(pdu[0], 0x42) << "function code for selector " << static_cast(selector); + EXPECT_EQ(pdu[1], 0x00) << "reserved byte for selector " << static_cast(selector); + EXPECT_EQ(pdu[2], selector); + } +} + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/test.esp32-idf.yaml b/tests/components/pzem6l24/test.esp32-idf.yaml new file mode 100644 index 00000000000..9fb11cd095c --- /dev/null +++ b/tests/components/pzem6l24/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO13 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.esp8266-ard.yaml b/tests/components/pzem6l24/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e2b6a4d58f6 --- /dev/null +++ b/tests/components/pzem6l24/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.rp2040-ard.yaml b/tests/components/pzem6l24/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e2b164efd25 --- /dev/null +++ b/tests/components/pzem6l24/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + pzem6l24: !include common.yaml From cfb7d8089e3a4fcbc1166bdcec2d95e1e9174712 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:21 +0000 Subject: [PATCH 534/759] [remote_base] Add AEG Hob2Hood Infrared Protocol (#4687) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 57 ++++++++++++- .../remote_base/hob2hood_protocol.cpp | 83 +++++++++++++++++++ .../remote_base/hob2hood_protocol.h | 39 +++++++++ tests/components/remote_base/__init__.py | 14 ++++ .../components/remote_base/hob2hood_test.cpp | 83 +++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_receiver/esp32-common.yaml | 4 + .../remote_transmitter/common-buttons.yaml | 5 ++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/hob2hood_protocol.cpp create mode 100644 esphome/components/remote_base/hob2hood_protocol.h create mode 100644 tests/components/remote_base/__init__.py create mode 100644 tests/components/remote_base/hob2hood_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e542ba3ed8f..befef872f0a 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -47,7 +47,7 @@ from esphome.const import ( from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] @@ -2106,6 +2106,61 @@ async def aeha_action(var, config, args): cg.add(var.set_carrier_frequency(templ)) +# Hob2Hood +( + Hob2HoodData, + Hob2HoodBinarySensor, + Hob2HoodTrigger, + Hob2HoodAction, + Hob2HoodDumper, +) = declare_protocol("Hob2Hood") + +Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") +HOB2HOOD_COMMAND_OPTIONS = { + "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, + "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, + "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, + "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, + "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, + "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, + "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, +} + +HOB2HOOD_SCHEMA = cv.Schema( + {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} +) + + +@register_binary_sensor("hob2hood", Hob2HoodBinarySensor, HOB2HOOD_SCHEMA) +def hob2hood_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + Hob2HoodData, + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("hob2hood", Hob2HoodTrigger, Hob2HoodData) +def hob2hood_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("hob2hood", Hob2HoodDumper) +def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) +async def hob2hood_action( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: + template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) + cg.add(var.set_command(template_)) + + # Haier HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( "Haier" diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 00000000000..9e706b9fe66 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.cpp @@ -0,0 +1,83 @@ +#include "hob2hood_protocol.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::remote_base { + +static const char *const TAG = "remote.hob2hood"; + +// A frame is 25 bits: a leading 0, then the command byte, command + 1 and command + 2. Zero bits are marks +// and one bits are spaces; equal neighbours merge into one run of n * BIT_TIME_US plus a fixed adjustment. +static constexpr uint8_t NBITS = 25; +static constexpr uint32_t BIT_TIME_US = 700; +static constexpr int32_t MARK_ADJUST_US = 300; +static constexpr int32_t SPACE_ADJUST_US = -200; +// The longest frame (light_off) has 18 runs +static constexpr uint8_t MAX_RUNS = 18; + +static constexpr std::array COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; +// Same order as COMMANDS; the last entry is the fallback +PROGMEM_STRING_TABLE(Hob2HoodCommandNames, "light_off", "light_on", "fan_off", "fan_low", "fan_medium", "fan_high", + "fan_max", "unknown"); + +// Walks the frame of `command` as runs of equal bits. emit(is_mark, length_us, is_last) returns false to stop. +template static bool walk_runs(Hob2HoodCommand command, F &&emit) { + // Shifted so the first of the 25 bits is the top bit + uint32_t bits = ((uint32_t(command) << 16) | (uint32_t(uint8_t(command + 1)) << 8) | uint8_t(command + 2)) + << (32 - NBITS); + int8_t run = 0; + for (uint8_t i = 0; i < NBITS; i++, bits <<= 1) { + const bool bit = (bits & 0x80000000) != 0; + run += bit ? 1 : -1; + const bool last = i == NBITS - 1; + if (last || (((bits << 1) & 0x80000000) != 0) != bit) { + const uint32_t length = BIT_TIME_US * std::abs(run) + (run < 0 ? MARK_ADJUST_US : SPACE_ADJUST_US); + if (!emit(run < 0, length, last)) + return false; + run = 0; + } + } + return true; +} + +void Hob2HoodProtocol::encode(RemoteTransmitData *dst, const Hob2HoodData &data) { + dst->set_carrier_frequency(38000); + dst->reserve(MAX_RUNS); + walk_runs(data.command, [dst](bool is_mark, uint32_t length, bool) { + if (is_mark) { + dst->mark(length); + } else { + dst->space(length); + } + return true; + }); +} + +optional Hob2HoodProtocol::decode(RemoteReceiveData src) { + for (auto command : COMMANDS) { + src.reset(); + // The receiver does not capture a trailing space, so the last run only has to match when it is a mark + const bool matched = walk_runs(command, [&src](bool is_mark, uint32_t length, bool last) { + return is_mark ? src.expect_mark(length) : (last || src.expect_space(length)); + }); + if (matched) + return Hob2HoodData{command}; + } + return {}; +} + +void Hob2HoodProtocol::dump(const Hob2HoodData &data) { + uint8_t index = 0; + while (index < COMMANDS.size() && COMMANDS[index] != data.command) + index++; + ESP_LOGI(TAG, "Received Hob2Hood: %s", + LOG_STR_ARG(Hob2HoodCommandNames::get_log_str(index, Hob2HoodCommandNames::LAST_INDEX))); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h new file mode 100644 index 00000000000..44bd873a65b --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +enum Hob2HoodCommand : uint8_t { + HOB2HOOD_COMMAND_LIGHT_OFF = 0xd5, + HOB2HOOD_COMMAND_LIGHT_ON = 0xd2, + HOB2HOOD_COMMAND_FAN_OFF = 0xd8, + HOB2HOOD_COMMAND_FAN_LOW = 0x6c, + HOB2HOOD_COMMAND_FAN_MEDIUM = 0x6f, + HOB2HOOD_COMMAND_FAN_HIGH = 0xe1, + HOB2HOOD_COMMAND_FAN_MAX = 0x72, +}; + +struct Hob2HoodData { + Hob2HoodCommand command; + bool operator==(const Hob2HoodData &rhs) const { return this->command == rhs.command; } +}; + +class Hob2HoodProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const Hob2HoodData &data); + optional decode(RemoteReceiveData src); + void dump(const Hob2HoodData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Hob2Hood) + +template class Hob2HoodAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(Hob2HoodCommand, command) + void encode(RemoteTransmitData *dst, Ts... x) override { + Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); + } +}; + +} // namespace esphome::remote_base diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..714f8ab571a --- /dev/null +++ b/tests/components/remote_base/__init__.py @@ -0,0 +1,14 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + async def to_code_testing(config: ConfigType) -> None: + # Protocol sources are compiled only behind their define; keep the ones under test. + request_protocol("hob2hood") + + manifest.to_code = to_code_testing + # AUTO_LOAD is not resolved by the unit test build. + manifest.dependencies = manifest.dependencies + ["binary_sensor"] diff --git a/tests/components/remote_base/hob2hood_test.cpp b/tests/components/remote_base/hob2hood_test.cpp new file mode 100644 index 00000000000..223e15fe495 --- /dev/null +++ b/tests/components/remote_base/hob2hood_test.cpp @@ -0,0 +1,83 @@ +#include +#include "esphome/components/remote_base/hob2hood_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr std::array ALL_COMMANDS = { + HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW, + HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX, +}; + +RawTimings encode(Hob2HoodCommand command) { + RemoteTransmitData data; + Hob2HoodProtocol().encode(&data, Hob2HoodData{command}); + return data.get_data(); +} + +optional decode(const RawTimings &timings, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + return Hob2HoodProtocol().decode(RemoteReceiveData(timings, tolerance, mode)); +} + +void expect_decodes_to(const RawTimings &timings, Hob2HoodCommand command, uint32_t tolerance = 25, + ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) { + auto decoded = decode(timings, tolerance, mode); + ASSERT_TRUE(decoded.has_value()) << "command 0x" << std::hex << int(command); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->command, command); + } +} + +} // namespace + +// light_on is 0xd2: the bits 0 11010010 11010011 11010100 form 17 runs. A run of n zero bits is a mark of +// n * 700 + 300 us; a run of n one bits is a space of n * 700 - 200 us. +TEST(Hob2HoodProtocolTest, EncodesTheDocumentedTimings) { + const RawTimings expected = {1000, -1200, 1000, -500, 1700, -500, 1000, -1200, 1000, + -500, 1700, -2600, 1000, -500, 1000, -500, 1700}; + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_ON), expected); +} + +TEST(Hob2HoodProtocolTest, LongestFrameFitsTheReservedLength) { + for (auto command : ALL_COMMANDS) { + EXPECT_LE(encode(command).size(), 18u) << "command 0x" << std::hex << int(command); + } + EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_OFF).size(), 18u); +} + +TEST(Hob2HoodProtocolTest, RoundTripsEveryCommand) { + for (auto command : ALL_COMMANDS) { + expect_decodes_to(encode(command), command); + } +} + +// A receiver never captures the trailing space, so a frame that ends in one must still decode without it. +TEST(Hob2HoodProtocolTest, DecodesWithoutTheTrailingSpace) { + for (auto command : ALL_COMMANDS) { + auto timings = encode(command); + if (timings.back() < 0) + timings.pop_back(); + expect_decodes_to(timings, command); + } +} + +// Real receivers shorten marks and lengthen spaces by a couple of hundred microseconds, which is why the +// documentation recommends a 350us tolerance. +TEST(Hob2HoodProtocolTest, DecodesSkewedTimingsWithinTheRecommendedTolerance) { + auto timings = encode(HOB2HOOD_COMMAND_FAN_HIGH); + for (auto &t : timings) { + t += t > 0 ? -240 : -230; + } + expect_decodes_to(timings, HOB2HOOD_COMMAND_FAN_HIGH, 350, TOLERANCE_MODE_TIME); +} + +TEST(Hob2HoodProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 26a02d4dabc..5d12dc68d43 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -172,3 +172,8 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_hob2hood: + then: + - logger.log: + format: "on_hob2hood: %u" + args: ["x.command"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 14effcbd2c5..956cfdc3233 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,3 +15,7 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: Hob2Hood Light On + hob2hood: + command: light_on diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 1f98646d931..a41ba346aa1 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -278,3 +278,8 @@ button: - remote_transmitter.digital_write: transmitter_id: xmitr value: !lambda return id(remote_transmitter_test_number).state > 127; + - platform: template + name: Hob2Hood + on_press: + - remote_transmitter.transmit_hob2hood: + command: light_on From 87b10d70a52ecd85b872515cd1c1f666df776f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:05:06 +0200 Subject: [PATCH 535/759] [mk2pvrouter] Add tag-based sensor defaults (#18870) 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 --- esphome/components/mk2pvrouter/mk2pvrouter.h | 2 +- .../components/mk2pvrouter/sensor/__init__.py | 109 ++++++++++++++++- .../mk2pvrouter/sensor/mk2pvrouter_sensor.cpp | 9 +- .../mk2pvrouter/sensor/mk2pvrouter_sensor.h | 8 +- tests/component_tests/mk2pvrouter/__init__.py | 0 .../mk2pvrouter/test_sensor_defaults.py | 84 +++++++++++++ tests/components/mk2pvrouter/__init__.py | 11 ++ tests/components/mk2pvrouter/common.h | 55 +++++++++ tests/components/mk2pvrouter/common.yaml | 71 ++++++----- .../mk2pvrouter/mk2pvrouter_test.cpp | 112 ++++++++++++++++++ .../sensor/mk2pvrouter_sensor_test.cpp | 25 ++++ 11 files changed, 449 insertions(+), 37 deletions(-) create mode 100644 tests/component_tests/mk2pvrouter/__init__.py create mode 100644 tests/component_tests/mk2pvrouter/test_sensor_defaults.py create mode 100644 tests/components/mk2pvrouter/__init__.py create mode 100644 tests/components/mk2pvrouter/common.h create mode 100644 tests/components/mk2pvrouter/mk2pvrouter_test.cpp create mode 100644 tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index fc23cf49e80..1a6af4b1b39 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -33,7 +33,7 @@ class Mk2PVRouterListener { }; // Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. -class Mk2PVRouter final : public Component, public uart::UARTDevice { +class Mk2PVRouter : public Component, public uart::UARTDevice { public: #ifdef MK2PVROUTER_LISTENER_COUNT void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py index 14fc48a6260..92ca6ea898a 100644 --- a/esphome/components/mk2pvrouter/sensor/__init__.py +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -1,6 +1,31 @@ +from dataclasses import dataclass +from typing import Any + import esphome.codegen as cg from esphome.components import sensor -from esphome.const import CONF_ID, CONF_TAG +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_STATE_CLASS, + CONF_TAG, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_NONE, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_PERCENT, + UNIT_VOLT, + UNIT_WATT, + UNIT_WATT_HOURS, +) from esphome.types import ConfigType from .. import ( @@ -14,13 +39,89 @@ Mk2PVRouterSensor = mk2pvrouter_ns.class_( "Mk2PVRouterSensor", sensor.Sensor, cg.Component ) -CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( - MK2PVROUTER_LISTENER_SCHEMA + +@dataclass(frozen=True) +class TagKind: + """Sensor defaults for one kind of Mk2PVRouter output.""" + + unit_of_measurement: str + device_class: str + state_class: str + accuracy_decimals: int + # The device sends the value * 100; Mk2PVRouterSensor::publish_val() corrects it. + scale_centi: bool = False + + def defaults(self) -> dict[str, Any]: + return { + CONF_UNIT_OF_MEASUREMENT: self.unit_of_measurement, + CONF_DEVICE_CLASS: self.device_class, + CONF_STATE_CLASS: self.state_class, + CONF_ACCURACY_DECIMALS: self.accuracy_decimals, + } + + +POWER = TagKind(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT, 0) +VOLTAGE = TagKind( + UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +ENERGY = TagKind(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING, 0) +TEMPERATURE = TagKind( + UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, 2, scale_centi=True +) +RELAY_STATE = TagKind(UNIT_EMPTY, DEVICE_CLASS_EMPTY, STATE_CLASS_NONE, 0) +DIVERSION_RATE = TagKind(UNIT_PERCENT, DEVICE_CLASS_EMPTY, STATE_CLASS_MEASUREMENT, 0) + +# Keyed by (letter, indexed). Per the Mk2PVRouter firmware protocol, D and R mean different +# things bare and indexed: D is diverted power (W) but D1, D2, ... are diversion rates (%); +# R is mean relay power (W) but R1, R2, ... are relay states. T is always indexed, E never. +TAG_KINDS = { + ("P", False): POWER, + ("P", True): POWER, + ("D", False): POWER, + ("D", True): DIVERSION_RATE, + ("V", False): VOLTAGE, + ("V", True): VOLTAGE, + ("E", False): ENERGY, + ("T", True): TEMPERATURE, + ("R", False): POWER, + ("R", True): RELAY_STATE, +} + + +def tag_kind(tag: str) -> TagKind | None: + """The kind of a tag such as P, V1 or R10, or None for a tag that is not a known output.""" + tag = tag.upper() + index = tag[1:] + if index and not index.isdigit(): + return None + return TAG_KINDS.get((tag[:1], bool(index))) + + +def _inject_tag_defaults(config: ConfigType) -> ConfigType: + """Fill in the tag's defaults for keys the user did not set; the sensor schema validates them.""" + if ( + isinstance(config, dict) + and isinstance(config.get(CONF_TAG), str) + and (kind := tag_kind(config[CONF_TAG])) is not None + ): + config = {**kind.defaults(), **config} + return config + + +CONFIG_SCHEMA = cv.All( + _inject_tag_defaults, + sensor.sensor_schema( + Mk2PVRouterSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(MK2PVROUTER_LISTENER_SCHEMA), ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + tag = config[CONF_TAG] + kind = tag_kind(tag) + var = cg.new_Pvariable(config[CONF_ID], tag, kind is not None and kind.scale_centi) await cg.register_component(var, config) await sensor.register_sensor(var, config) mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp index 96f1ff5954e..5ea07307e49 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -5,7 +5,8 @@ namespace esphome::mk2pvrouter { static const char *const TAG = "mk2pvrouter_sensor"; -Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag, bool scale_centi) + : Mk2PVRouterListener(tag), scale_centi_(scale_centi) {} void Mk2PVRouterSensor::publish_val(const char *val) { auto result = parse_number(val); @@ -13,7 +14,11 @@ void Mk2PVRouterSensor::publish_val(const char *val) { ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); return; } - this->publish_state(result.value()); + float value = result.value(); + if (this->scale_centi_) { + value *= 0.01f; + } + this->publish_state(value); } void Mk2PVRouterSensor::dump_config() { diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h index e4da41e384b..6ae4f2f0ad8 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -5,11 +5,15 @@ namespace esphome::mk2pvrouter { -class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { +// Component before Sensor so the flag lands in Sensor's tail padding +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public Component, public sensor::Sensor { public: - explicit Mk2PVRouterSensor(const char *tag); + Mk2PVRouterSensor(const char *tag, bool scale_centi); void publish_val(const char *val) override; void dump_config() override; + + protected: + bool scale_centi_; }; } // namespace esphome::mk2pvrouter diff --git a/tests/component_tests/mk2pvrouter/__init__.py b/tests/component_tests/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mk2pvrouter/test_sensor_defaults.py b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py new file mode 100644 index 00000000000..165adb676a7 --- /dev/null +++ b/tests/component_tests/mk2pvrouter/test_sensor_defaults.py @@ -0,0 +1,84 @@ +"""Tag-based sensor defaults, driven through the real CONFIG_SCHEMA.""" + +import pytest + +from esphome.components import sensor +from esphome.components.mk2pvrouter.sensor import CONFIG_SCHEMA, tag_kind +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_EMPTY, + STATE_CLASS_MEASUREMENT, +) +from esphome.types import ConfigType + + +def _sensor(tag: str, **extra: object) -> ConfigType: + return CONFIG_SCHEMA( + {"tag": tag, "mk2pvrouter_id": "hub", "name": f"{tag} sensor", **extra} + ) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class", "decimals"), + [ + ("P", "W", "power", "measurement", 0), + ("P1", "W", "power", "measurement", 0), + ("D", "W", "power", "measurement", 0), + ("D1", "%", "", "measurement", 0), + ("V", "V", "voltage", "measurement", 2), + ("v1", "V", "voltage", "measurement", 2), + ("E", "Wh", "energy", "total_increasing", 0), + ("T1", "°C", "temperature", "measurement", 2), + ("R", "W", "power", "measurement", 0), + ("R1", "", "", "", 0), + ("R10", "", "", "", 0), + ], +) +def test_tag_defaults( + tag: str, unit: str, device_class: str, state_class: str, decimals: int +) -> None: + config = _sensor(tag) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == sensor.validate_state_class(state_class) + assert config[CONF_ACCURACY_DECIMALS] == decimals + + +@pytest.mark.parametrize("tag", ["S_MC", "STATUS", "X9", "Z", "T", "E1"]) +def test_unknown_tag_gets_only_the_schema_defaults(tag: str) -> None: + config = _sensor(tag) + assert CONF_UNIT_OF_MEASUREMENT not in config + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_MEASUREMENT + ) + assert config[CONF_ACCURACY_DECIMALS] == 0 + + +def test_explicit_values_win_over_tag_defaults() -> None: + config = _sensor("P", device_class=DEVICE_CLASS_EMPTY, accuracy_decimals=3) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_EMPTY + assert config[CONF_ACCURACY_DECIMALS] == 3 + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + + +@pytest.mark.parametrize( + ("tag", "scale_centi"), + [ + ("V", True), + ("V1", True), + ("v2", True), + ("T1", True), + ("P", False), + ("D1", False), + ("E", False), + ("T", False), + ("X9", False), + ], +) +def test_centi_scale_follows_the_tag(tag: str, scale_centi: bool) -> None: + kind = tag_kind(tag) + assert (kind is not None and kind.scale_centi) is scale_centi diff --git a/tests/components/mk2pvrouter/__init__.py b/tests/components/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..a750a4217c9 --- /dev/null +++ b/tests/components/mk2pvrouter/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per hub. + cg.add_define("MK2PVROUTER_LISTENER_COUNT", 1) + + manifest.to_code = to_code_testing diff --git a/tests/components/mk2pvrouter/common.h b/tests/components/mk2pvrouter/common.h new file mode 100644 index 00000000000..c954c8bb698 --- /dev/null +++ b/tests/components/mk2pvrouter/common.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" + +namespace esphome::mk2pvrouter::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +class TestableMk2PVRouter : public Mk2PVRouter { + public: + using Mk2PVRouter::Mk2PVRouter; + using Mk2PVRouter::State; + using Mk2PVRouter::buf_index_; + using Mk2PVRouter::calculate_crc_; + using Mk2PVRouter::check_crc_; + using Mk2PVRouter::process_group_; + using Mk2PVRouter::state_; +}; + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml index 4421c098545..cdd4a70f817 100644 --- a/tests/components/mk2pvrouter/common.yaml +++ b/tests/components/mk2pvrouter/common.yaml @@ -3,44 +3,59 @@ mk2pvrouter: uart_id: uart_bus sensor: + # Exact tags - platform: mk2pvrouter name: Power tag: P mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: W - device_class: power - state_class: measurement - accuracy_decimals: 0 - - - platform: mk2pvrouter - name: Voltage - tag: V - mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: V - device_class: voltage - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends voltage * 100 - - multiply: 0.01 + # An explicit value must win over the tag default + device_class: "" - platform: mk2pvrouter name: Energy tag: E mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: Wh - device_class: energy - state_class: total_increasing - accuracy_decimals: 0 + + # Indexed tags + - platform: mk2pvrouter + name: Power Phase 1 + tag: P1 + mk2pvrouter_id: test_mk2pvrouter - platform: mk2pvrouter - name: Temperature + name: Power Phase 2 + tag: P2 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Voltage Phase 1 + tag: V1 + mk2pvrouter_id: test_mk2pvrouter + + # The x0.01 correction is applied before filters, so a user filter stacks on the corrected value + - platform: mk2pvrouter + name: Voltage Phase 2 Scaled + tag: V2 + mk2pvrouter_id: test_mk2pvrouter + filters: + - multiply: 1.0 + + - platform: mk2pvrouter + name: Temperature 1 tag: T1 mk2pvrouter_id: test_mk2pvrouter - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement - accuracy_decimals: 2 - filters: - # Device sends temperature * 100 - - multiply: 0.01 + + - platform: mk2pvrouter + name: Diversion Rate 1 + tag: D1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 1 State + tag: R1 + mk2pvrouter_id: test_mk2pvrouter + + - platform: mk2pvrouter + name: Relay 10 State + tag: R10 + mk2pvrouter_id: test_mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp new file mode 100644 index 00000000000..84106eb0b77 --- /dev/null +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -0,0 +1,112 @@ +#include + +#include + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "common.h" + +namespace esphome::mk2pvrouter::testing { + +namespace { +class TestListener final : public Mk2PVRouterListener { + public: + explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} + void publish_val(const char *val) override { + this->published_ = true; + this->last_val_ = val; + } + + bool published_{false}; + std::string last_val_; +}; + +class Mk2PVRouterTest : public ::testing::Test { + protected: + void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } + + // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. + template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + + TestableMk2PVRouter sut_; + TestListener listener_{"P1"}; +}; +} // namespace + +TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { + TestListener listener("P1"); + EXPECT_STREQ(listener.get_tag(), "P1"); +} + +TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { + // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. + const char grp[] = "P1\t1234\t="; + EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); +} + +TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { + const char grp[] = "P1\t1234\t="; + EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { + const char grp[] = "P1\t1234\t!"; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); +} + +TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { + const char grp[] = ""; + EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { + this->process_("P1\t1234\t="); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupWithBadCrc) { + this->process_("P1\t1234\t!"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, ProcessGroupDropsGroupMissingValue) { + // "P1\t" sums to 0x2A ('*'), so the CRC passes, but there is no second TAB for the value. + this->process_("P1\t*"); + EXPECT_FALSE(this->listener_.published_); +} + +TEST_F(Mk2PVRouterTest, LoopParsesFullFrameAndPublishesToListener) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + + // STX, LF "P1\t1234\t=" CR, ETX + uart.push_rx({0x02, 0x0a, 'P', '1', '\t', '1', '2', '3', '4', '\t', '=', 0x0d, 0x03}); + + this->sut_.loop(); // Consume bytes up to and including START_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + + this->sut_.loop(); // Buffer the frame body up to END_FRAME. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::END_FRAME_RECEIVED); + + this->sut_.loop(); // Parse the buffered groups and publish them. + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::WAITING_FOR_START); + EXPECT_EQ(this->sut_.buf_index_, 0); + EXPECT_TRUE(this->listener_.published_); + EXPECT_EQ(this->listener_.last_val_, "1234"); +} + +TEST_F(Mk2PVRouterTest, LoopIgnoresBytesBeforeStartFrame) { + MockUARTComponent uart; + this->sut_.set_uart_parent(&uart); + + uart.push_rx({'g', 'a', 'r', 'b', 'a', 'g', 'e', 0x02}); + + this->sut_.loop(); + + EXPECT_EQ(this->sut_.state_, TestableMk2PVRouter::State::START_FRAME_RECEIVED); + EXPECT_EQ(this->sut_.buf_index_, 0); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp new file mode 100644 index 00000000000..cf70a32a650 --- /dev/null +++ b/tests/components/mk2pvrouter/sensor/mk2pvrouter_sensor_test.cpp @@ -0,0 +1,25 @@ +#include + +#include "esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterSensorTest, ScaleCentiTrueScalesByOneHundredth) { + Mk2PVRouterSensor sensor("V", true); + sensor.publish_val("23042"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 230.42f); +} + +TEST(Mk2PVRouterSensorTest, ScaleCentiFalseDoesNotScale) { + Mk2PVRouterSensor sensor("P1", false); + sensor.publish_val("1234"); + EXPECT_FLOAT_EQ(sensor.get_raw_state(), 1234.0f); +} + +TEST(Mk2PVRouterSensorTest, UnparseableValueIsNotPublished) { + Mk2PVRouterSensor sensor("V1", true); + sensor.publish_val("not-a-number"); + EXPECT_FALSE(sensor.has_state()); +} + +} // namespace esphome::mk2pvrouter::testing From bcae1f28ef8342cdc1d2fb1cd68b05071b545a77 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sat, 26 Sep 2026 02:11:25 +0200 Subject: [PATCH 536/759] [web_server] Expose temperature units for climate and water heater entities (#19572) --- esphome/components/web_server/web_server.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6e48e2700f7..fef484cd669 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1600,6 +1600,7 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("min_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_min_temperature(), target_accuracy), temp_buf); root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } @@ -1981,6 +1982,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); + root[ESPHOME_F("temperature_unit")] = static_cast(traits.get_temperature_unit()); this->add_sorting_info_(root, obj); } From 2f4eb3e1eea425d415b47e2f7054a2e21652588c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:28:40 +0100 Subject: [PATCH 537/759] [audio_adc] Use register_apply_action for audio_adc.set_mic_gain (#19679) --- esphome/components/audio_adc/__init__.py | 25 ++++------------------- esphome/components/audio_adc/automation.h | 21 ------------------- 2 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 esphome/components/audio_adc/automation.h diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index c2bdfb6cb0b..7d4b51db047 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -1,9 +1,8 @@ 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 ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.const import CONF_MIC_GAIN +from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -12,8 +11,6 @@ IS_PLATFORM_COMPONENT = True audio_adc_ns = cg.esphome_ns.namespace("audio_adc") AudioAdc = audio_adc_ns.class_("AudioAdc") -SetMicGainAction = audio_adc_ns.class_("SetMicGainAction", automation.Action) - SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( { @@ -24,25 +21,11 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "audio_adc.set_mic_gain", - SetMicGainAction, SET_MIC_GAIN_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MIC_GAIN, "set_mic_gain", cg.float_), ) -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) - - template_ = await cg.templatable(config.get(CONF_MIC_GAIN), args, cg.float_) - cg.add(var.set_mic_gain(template_)) - - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h deleted file mode 100644 index fc7af256228..00000000000 --- a/esphome/components/audio_adc/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "audio_adc.h" - -namespace esphome::audio_adc { - -template class SetMicGainAction final : public Action { - public: - explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} - - TEMPLATABLE_VALUE(float, mic_gain) - - void play(const Ts &...x) override { this->audio_adc_->set_mic_gain(this->mic_gain_.value(x...)); } - - protected: - AudioAdc *audio_adc_; -}; - -} // namespace esphome::audio_adc From 1889f6c9b55d828c3a615a39f106b52d32bff797 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:28:58 +0100 Subject: [PATCH 538/759] [max17043] Use register_apply_action for max17043.sleep_mode (#19682) --- esphome/components/max17043/automation.h | 18 ------------------ esphome/components/max17043/sensor.py | 10 ++-------- 2 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 esphome/components/max17043/automation.h diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h deleted file mode 100644 index 6b19e5bd5e9..00000000000 --- a/esphome/components/max17043/automation.h +++ /dev/null @@ -1,18 +0,0 @@ - -#pragma once -#include "esphome/core/automation.h" -#include "max17043.h" - -namespace esphome::max17043 { - -template class SleepAction final : public Action { - public: - explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} - - void play(const Ts &...x) override { this->max17043_->sleep_mode(); } - - protected: - MAX17043Component *max17043_; -}; - -} // namespace esphome::max17043 diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 7de1424a96d..0b72955c74e 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -23,9 +23,6 @@ MAX17043Component = max17043_ns.class_( "MAX17043Component", cg.PollingComponent, i2c.I2CDevice ) -# Actions -SleepAction = max17043_ns.class_("SleepAction", automation.Action) - CONFIG_SCHEMA = ( cv.Schema( { @@ -72,9 +69,6 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -automation.register_simple_action( - "max17043.sleep_mode", - SleepAction, - MAX17043_ACTION_SCHEMA, - synchronous=True, +automation.register_apply_action( + "max17043.sleep_mode", MAX17043_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()") ) From 3e85af29c60e8067d02ac565bbd6b00188a7cd07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:29:04 +0100 Subject: [PATCH 539/759] [hc8] Use register_apply_action for hc8.calibrate (#19681) --- esphome/components/hc8/hc8.h | 8 -------- esphome/components/hc8/sensor.py | 19 ++----------------- tests/components/hc8/common-actions.yaml | 7 +++++++ .../hc8/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 17 insertions(+), 25 deletions(-) create mode 100644 tests/components/hc8/common-actions.yaml create mode 100644 tests/components/hc8/test-actions.esp8266-ard.yaml diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index 681dffe4f6c..d3399c8a2d1 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" @@ -26,11 +25,4 @@ class HC8Component final : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint16_t, baseline) - - void play(const Ts &...x) override { this->parent_->calibrate(this->baseline_.value(x...)); } -}; - } // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 8a19cce8d18..05d8e233ef3 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,15 +12,12 @@ 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"] hc8_ns = cg.esphome_ns.namespace("hc8") HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) -HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -73,20 +70,8 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "hc8.calibrate", - HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_BASELINE, "calibrate", cg.uint16), ) -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) - cg.add(var.set_baseline(template_)) - return var diff --git a/tests/components/hc8/common-actions.yaml b/tests/components/hc8/common-actions.yaml new file mode 100644 index 00000000000..798ec4cbe6b --- /dev/null +++ b/tests/components/hc8/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: HC8 Calibrate + on_press: + - hc8.calibrate: + id: hc8_sensor + baseline: !lambda return 400; diff --git a/tests/components/hc8/test-actions.esp8266-ard.yaml b/tests/components/hc8/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c19ee22eb39 --- /dev/null +++ b/tests/components/hc8/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + hc8: !include common.yaml + actions: !include common-actions.yaml From 5043e12690ed8323f9180c1831c19762a90388e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:29:14 +0100 Subject: [PATCH 540/759] [cs5460a] Use register_apply_action for cs5460a.restart (#19683) --- esphome/components/cs5460a/cs5460a.h | 11 ----------- esphome/components/cs5460a/sensor.py | 6 ++---- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 87ea858c70c..80a63d808fb 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" @@ -108,14 +107,4 @@ class CS5460AComponent final : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction final : public Action { - public: - CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} - - void play(const Ts &...x) override { cs5460a_->restart(); } - - protected: - CS5460AComponent *cs5460a_; -}; - } // namespace esphome::cs5460a diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 946037fc34d..17ea41ed04d 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -30,7 +30,6 @@ PGA_GAIN_OPTIONS = { } CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) -CS5460ARestartAction = cs5460a_ns.class_("CS5460ARestartAction", automation.Action) CONF_SAMPLES = "samples" CONF_PHASE_OFFSET = "phase_offset" @@ -129,13 +128,12 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_power_sensor(sens)) -automation.register_simple_action( +automation.register_apply_action( "cs5460a.restart", - CS5460ARestartAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(CS5460AComponent), } ), - synchronous=True, + automation.ApplyCall("restart()"), ) From d34b21cb7efe1cec1a1325dbf7b62f35a11ae6e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:12 +0100 Subject: [PATCH 541/759] [pipsolar] Use register_apply_action for output.pipsolar.set_level (#19685) --- .../components/pipsolar/output/__init__.py | 19 ++----------------- .../pipsolar/output/pipsolar_output.h | 12 ------------ tests/components/pipsolar/common-actions.yaml | 7 +++++++ .../pipsolar/test-actions.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 17 insertions(+), 29 deletions(-) create mode 100644 tests/components/pipsolar/common-actions.yaml create mode 100644 tests/components/pipsolar/test-actions.esp8266-ard.yaml diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 62e6d0f113a..e3c841d1dde 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -3,8 +3,6 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_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 @@ -12,7 +10,6 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns DEPENDENCIES = ["pipsolar"] PipsolarOutput = pipsolar_ns.class_("PipsolarOutput", output.FloatOutput) -SetOutputAction = pipsolar_ns.class_("SetOutputAction", automation.Action) CONF_POSSIBLE_VALUES = "possible_values" @@ -92,25 +89,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_possible_values(conf[CONF_POSSIBLE_VALUES])) -@automation.register_action( +automation.register_apply_action( "output.pipsolar.set_level", - SetOutputAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(PipsolarOutput), cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -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_) - cg.add(var.set_level(template_)) - return var diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 6fc013c2765..bf82fa63a81 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -27,16 +27,4 @@ class PipsolarOutput final : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction final : public Action { - public: - SetOutputAction(PipsolarOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_value(this->level_.value(x...)); } - - protected: - PipsolarOutput *output_; -}; - } // namespace esphome::pipsolar diff --git a/tests/components/pipsolar/common-actions.yaml b/tests/components/pipsolar/common-actions.yaml new file mode 100644 index 00000000000..960c512a1dc --- /dev/null +++ b/tests/components/pipsolar/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Pipsolar Set Level + on_press: + - output.pipsolar.set_level: + id: inverter0_battery_recharge_voltage_out + value: !lambda return 49.0; diff --git a/tests/components/pipsolar/test-actions.esp8266-ard.yaml b/tests/components/pipsolar/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..38154131053 --- /dev/null +++ b/tests/components/pipsolar/test-actions.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + pipsolar: !include common.yaml + actions: !include common-actions.yaml From 3a9048cc62c898e59a2de81a74264350a858949b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:16 +0100 Subject: [PATCH 542/759] [speaker_source] Use register_apply_action for speaker_source.set_playlist_delay (#19686) --- .../components/speaker_source/automation.h | 29 ------------------ .../components/speaker_source/media_player.py | 30 ++++--------------- 2 files changed, 5 insertions(+), 54 deletions(-) delete mode 100644 esphome/components/speaker_source/automation.h diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h deleted file mode 100644 index a03fa424777..00000000000 --- a/esphome/components/speaker_source/automation.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "speaker_source_media_player.h" - -namespace esphome::speaker_source { - -template class SetPlaylistDelayAction final : public Action { - public: - explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(uint8_t, pipeline) - TEMPLATABLE_VALUE(uint32_t, delay) - - void play(const Ts &...x) override { - this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); - } - - protected: - SpeakerSourceMediaPlayer *parent_; -}; - -} // namespace esphome::speaker_source - -#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index b6653fe5433..4f150e87fff 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -16,8 +16,6 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_SPEAKER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -59,10 +57,6 @@ _PIPELINE_INFO = { ), } -SetPlaylistDelayAction = speaker_source_ns.class_( - "SetPlaylistDelayAction", automation.Action -) - _validate_pipeline = media_player.validate_preferred_format( "speaker_source media_player", CONF_SPEAKER @@ -232,25 +226,11 @@ SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "speaker_source.set_playlist_delay", - SetPlaylistDelayAction, SET_PLAYLIST_DELAY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall( + "set_playlist_delay_ms({}, {})", + ((CONF_PIPELINE, cg.uint8), (CONF_DELAY, cg.uint32)), + ), ) -async def set_playlist_delay_action_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_PIPELINE], args, cg.uint8) - cg.add(var.set_pipeline(template_)) - - template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) - cg.add(var.set_delay(template_)) - - return var From 532a6ed2391b41dd36d53bed44ac7782bf12c5e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:30:56 +0100 Subject: [PATCH 543/759] [mixer] Use register_apply_action for mixer_speaker.apply_ducking (#19689) --- esphome/components/mixer/speaker/__init__.py | 29 ++++--------------- esphome/components/mixer/speaker/automation.h | 18 ------------ tests/components/mixer/common-actions.yaml | 8 +++++ .../mixer/test-actions.esp32-idf.yaml | 8 +++++ 4 files changed, 21 insertions(+), 42 deletions(-) delete mode 100644 esphome/components/mixer/speaker/automation.h create mode 100644 tests/components/mixer/common-actions.yaml create mode 100644 tests/components/mixer/test-actions.esp32-idf.yaml diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 26619f35a76..44473b12c32 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,9 +15,7 @@ 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 @@ -32,10 +30,6 @@ CONF_DECIBEL_REDUCTION = "decibel_reduction" CONF_QUEUE_MODE = "queue_mode" CONF_SOURCE_SPEAKERS = "source_speakers" -DuckingApplyAction = mixer_speaker_ns.class_( - "DuckingApplyAction", automation.Action, cg.Parented.template(SourceSpeaker) -) - SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( { @@ -148,9 +142,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_source_speaker(source_speaker)) -@automation.register_action( +automation.register_apply_action( "mixer_speaker.apply_ducking", - DuckingApplyAction, cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), @@ -162,20 +155,8 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyCall( + "apply_ducking({}, {})", + ((CONF_DECIBEL_REDUCTION, cg.uint8), (CONF_DURATION, cg.uint32)), + ), ) -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( - config[CONF_DECIBEL_REDUCTION], args, cg.uint8 - ) - cg.add(var.set_decibel_reduction(decibel_reduction)) - duration = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(duration)) - return var diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h deleted file mode 100644 index ea51b6b8892..00000000000 --- a/esphome/components/mixer/speaker/automation.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mixer_speaker.h" - -#ifdef USE_ESP32 - -namespace esphome::mixer_speaker { -template class DuckingApplyAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, decibel_reduction); - TEMPLATABLE_VALUE(uint32_t, duration); - void play(const Ts &...x) override { - this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); - } -}; -} // namespace esphome::mixer_speaker - -#endif diff --git a/tests/components/mixer/common-actions.yaml b/tests/components/mixer/common-actions.yaml new file mode 100644 index 00000000000..f86009e6284 --- /dev/null +++ b/tests/components/mixer/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: Duck media + on_press: + - mixer_speaker.apply_ducking: + id: source_speaker_1_id + decibel_reduction: !lambda return 20; + duration: !lambda return 250; diff --git a/tests/components/mixer/test-actions.esp32-idf.yaml b/tests/components/mixer/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7c214bf8641 --- /dev/null +++ b/tests/components/mixer/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dout_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + mixer: !include common.yaml + actions: !include common-actions.yaml From c0c1643efec8dbfa7e2e76ec5db53a0ca936b2ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:05 +0100 Subject: [PATCH 544/759] [zigbee] Use register_apply_action for zigbee.factory_reset (#19690) --- esphome/components/zigbee/__init__.py | 11 ++--------- esphome/components/zigbee/automation.h | 19 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 esphome/components/zigbee/automation.h diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 8e9824d42e7..1fa6110f04b 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -30,7 +30,6 @@ from .const import ( POWER_SOURCE, REPORT, ZigbeeComponent, - zigbee_ns, ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, @@ -293,14 +292,8 @@ ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) -FactoryResetAction = zigbee_ns.class_( - "FactoryResetAction", automation.Action, cg.Parented.template(ZigbeeComponent) -) - - -automation.register_parented_action( +automation.register_apply_action( "zigbee.factory_reset", - FactoryResetAction, ZIGBEE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("factory_reset()"), ) diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h deleted file mode 100644 index 1f953100d90..00000000000 --- a/esphome/components/zigbee/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_ZIGBEE -#ifdef USE_ESP32 -#include "zigbee_esp32.h" -#endif -#ifdef USE_NRF52 -#include "zigbee_zephyr.h" -#endif -namespace esphome::zigbee { - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::zigbee - -#endif From d1dd4e53ce989fc6ed88edafc89970a74cde47ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:41 +0100 Subject: [PATCH 545/759] [zephyr_ble_server] Use register_apply_action for ble_server.numeric_comparison_reply (#19692) --- .../components/zephyr_ble_server/__init__.py | 25 +++---------------- .../components/zephyr_ble_server/ble_server.h | 12 --------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 463b9c08870..59e5bc556bf 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -3,8 +3,7 @@ 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, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE from esphome.types import ConfigType zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") @@ -49,10 +48,6 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -BLENumericComparisonReplyAction = zephyr_ble_server_ns.class_( - "BLENumericComparisonReplyAction", automation.Action -) - BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEServer), @@ -61,22 +56,8 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "ble_server.numeric_comparison_reply", - BLENumericComparisonReplyAction, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_ACCEPT, "numeric_comparison_reply", cg.bool_), ) -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) - - templ = await cg.templatable(config[CONF_ACCEPT], args, cg.bool_) - cg.add(var.set_accept(templ)) - - return var diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index 223dbf7ac98..ef3daff9164 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -21,17 +21,5 @@ class BLEServer final : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction final : public Action { - public: - explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(bool, accept) - - void play(const Ts &...x) override { this->parent_->numeric_comparison_reply(this->accept_.value(x...)); } - - protected: - BLEServer *parent_; -}; - } // namespace esphome::zephyr_ble_server #endif From c902083ef35937eb32f22f228e2c5e3e21321c3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:31:49 +0100 Subject: [PATCH 546/759] [dfrobot_sen0395] Use register_apply_action for dfrobot_sen0395.reset (#19693) --- esphome/components/dfrobot_sen0395/__init__.py | 8 ++------ esphome/components/dfrobot_sen0395/automation.h | 6 ------ esphome/components/dfrobot_sen0395/dfrobot_sen0395.h | 2 ++ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 81d57026cc9..42627a30f6f 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -20,9 +20,6 @@ DfrobotSen0395Component = dfrobot_sen0395_ns.class_( ) # Actions -DfrobotSen0395ResetAction = dfrobot_sen0395_ns.class_( - "DfrobotSen0395ResetAction", automation.Action -) DfrobotSen0395SettingsAction = dfrobot_sen0395_ns.class_( "DfrobotSen0395SettingsAction", automation.Action ) @@ -49,15 +46,14 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) -automation.register_parented_action( +automation.register_apply_action( "dfrobot_sen0395.reset", - DfrobotSen0395ResetAction, maybe_simple_id( { cv.GenerateID(): cv.use_id(DfrobotSen0395Component), } ), - synchronous=True, + automation.ApplyCall("reset_system()"), ) diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index a5f4c99014d..c3632085310 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -7,12 +7,6 @@ namespace esphome::dfrobot_sen0395 { -template -class DfrobotSen0395ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } -}; - template class DfrobotSen0395SettingsAction final : public Action, public Parented { public: diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 448a18a4775..500f20d8dab 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -94,6 +94,8 @@ class DfrobotSen0395Component final : public uart::UARTDevice, public Component #endif int8_t enqueue(std::unique_ptr cmd); + /// Queue a sensor reset + void reset_system() { this->enqueue(make_unique()); } protected: #ifdef USE_BINARY_SENSOR From 447348d617a7ebba7a8ead5683b26ccb1a4cd8de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:32:53 +0100 Subject: [PATCH 547/759] [lightwaverf] Use register_apply_action for lightwaverf.send_raw (#19698) --- esphome/components/lightwaverf/__init__.py | 68 ++++++++------------ esphome/components/lightwaverf/lightwaverf.h | 23 ------- 2 files changed, 26 insertions(+), 65 deletions(-) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 0f42083cb52..a1138c7de82 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,8 +11,6 @@ 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 @@ -24,7 +22,6 @@ lightwaverf_ns = cg.esphome_ns.namespace("lightwaverf") LIGHTWAVERFComponent = lightwaverf_ns.class_( "LightWaveRF", cg.Component, cg.PollingComponent ) -LightwaveRawAction = lightwaverf_ns.class_("SendRawAction", automation.Action) CONFIG_SCHEMA = cv.Schema( @@ -36,49 +33,36 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("1s")) -LIGHTWAVE_SEND_SCHEMA = cv.Any( - cv.int_range(min=1), - cv.Schema( - { - cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), - cv.Required(CONF_NAME): cv.string, - cv.Required(CONF_CODE): cv.All( - [cv.Any(cv.hex_uint8_t)], - cv.Length(min=10), - ), - cv.Optional(CONF_REPEAT, default=10): cv.int_, - cv.Optional(CONF_INVERTED, default=False): cv.boolean, - cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, - } +LIGHTWAVE_SEND_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent), + cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_CODE): cv.All( + [cv.Any(cv.hex_uint8_t)], + cv.Length(min=10), + ), + cv.Optional(CONF_REPEAT, default=10): cv.int_, + cv.Optional(CONF_INVERTED, default=False): cv.boolean, + cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_, + } +) + + +automation.register_apply_action( + "lightwaverf.send_raw", + LIGHTWAVE_SEND_SCHEMA, + automation.ApplyCall( + "send_rx({}, {}, {}, {})", + ( + (CONF_CODE, cg.std_vector.template(cg.uint8)), + (CONF_REPEAT, cg.uint8), + (CONF_INVERTED, cg.bool_), + (CONF_PULSE_LENGTH, cg.int_), + ), ), ) -@automation.register_action( - "lightwaverf.send_raw", - LightwaveRawAction, - LIGHTWAVE_SEND_SCHEMA, - synchronous=True, -) -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) - - template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) - cg.add(var.set_repeat(template_)) - template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) - cg.add(var.set_inverted(template_)) - template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) - cg.add(var.set_pulse_length(template_)) - cg.add(var.set_code(config[CONF_CODE])) - return var - - 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/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 36dac3c86fc..d14f57e31e9 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -4,7 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include @@ -37,28 +36,6 @@ class LightWaveRF final : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction final : public Action { - public: - SendRawAction(LightWaveRF *parent) : parent_(parent){}; - TEMPLATABLE_VALUE(int, repeat); - TEMPLATABLE_VALUE(int, inverted); - TEMPLATABLE_VALUE(int, pulse_length); - TEMPLATABLE_VALUE(std::vector, code); - void set_code(std::initializer_list data) { this->code_ = std::vector(data); } - - void play(const Ts &...x) { - int repeats = this->repeat_.value(x...); - int inverted = this->inverted_.value(x...); - int pulse_length = this->pulse_length_.value(x...); - std::vector msg = this->code_.value(x...); - - this->parent_->send_rx(msg, repeats, inverted, pulse_length); - } - - protected: - LightWaveRF *parent_; -}; - #endif } // namespace esphome::lightwaverf #endif From 01a2aaef0811e1cd4b270ac3c44729fd9911ad89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:32:57 +0100 Subject: [PATCH 548/759] [motion] Use register_apply_action for motion.clear_calibration (#19700) --- esphome/components/motion/__init__.py | 12 +----- .../components/motion/motion_component.cpp | 5 ++- esphome/components/motion/motion_component.h | 21 ++-------- tests/component_tests/motion/test_motion.py | 39 ------------------- tests/components/qmi8658/common-actions.yaml | 7 ++++ .../qmi8658/test-actions.esp8266-ard.yaml | 4 ++ 6 files changed, 20 insertions(+), 68 deletions(-) create mode 100644 tests/components/qmi8658/common-actions.yaml create mode 100644 tests/components/qmi8658/test-actions.esp8266-ard.yaml diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index aea052fa2f1..63a104f1a7e 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -25,7 +25,6 @@ CONF_TRANSFORM_MATRIX = "transform_matrix" CalibrateLevelAction = motion_ns.class_("CalibrateLevelAction", automation.Action) CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.Action) -ClearCalibrationAction = motion_ns.class_("ClearCalibrationAction", automation.Action) KEY_ACCELEROMETER = "accelerometer" KEY_GYROSCOPE = "gyroscope" @@ -207,15 +206,8 @@ CLEAR_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( "motion.clear_calibration", - ClearCalibrationAction, CLEAR_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_SAVE, "clear_calibration", cg.bool_), ) -async def clear_calibration_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - if config.get(CONF_SAVE): - cg.add(var.set_save(True)) - return var diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp index 8715c8385c9..226dc94e638 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -56,10 +56,13 @@ bool MotionComponent::save_calibration() { ESP_LOGW(TAG, "Calibration save failed"); return false; } -void MotionComponent::clear_calibration() { +void MotionComponent::clear_calibration(bool save) { memcpy(this->matrix_, this->base_matrix_, sizeof(this->matrix_)); ESP_LOGI(TAG, "Calibration reset to build-time matrix"); log_matrix(this->matrix_); + if (save) { + this->save_calibration(); + } } void MotionComponent::update() { if (this->is_failed()) diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index b0a074a17c2..d117ca00320 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -52,8 +52,9 @@ class MotionComponent : public PollingComponent { bool calibrate_heading(); /// Save the current matrix to NVS. bool save_calibration(); - /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration. - void clear_calibration(); + /// Restore the build-time (axis_map / transform_matrix) base, discarding calibration, + /// and persist that base when `save` is set. + void clear_calibration(bool save = false); template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } @@ -135,20 +136,4 @@ template class CalibrateHeadingAction final : public Action class ClearCalibrationAction final : public Action { - public: - explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} - void set_save(bool save) { this->save_ = save; } - - protected: - void play(const Ts &...) override { - this->parent_->clear_calibration(); - if (this->save_) - this->parent_->save_calibration(); - } - - MotionComponent *parent_; - bool save_{false}; -}; - } // namespace esphome::motion diff --git a/tests/component_tests/motion/test_motion.py b/tests/component_tests/motion/test_motion.py index f2c0f263442..c502e06dab4 100644 --- a/tests/component_tests/motion/test_motion.py +++ b/tests/component_tests/motion/test_motion.py @@ -18,7 +18,6 @@ from esphome.components.motion import ( _build_calibrate_action, _transform_matrix, _validate_matrix_options, - clear_calibration_to_code, ) from esphome.components.motion.sensor import ( _ACCELERATIONS, @@ -785,44 +784,6 @@ class TestClearActionSchema: assert result[CONF_SAVE] is False -@pytest.fixture -def mock_clear_codegen(): - """Mock cg functions used by clear_calibration_to_code.""" - mock_var = MagicMock() - mock_parent = MagicMock() - with ( - patch( - "esphome.components.motion.cg.get_variable", - new_callable=AsyncMock, - return_value=mock_parent, - ), - patch( - "esphome.components.motion.cg.new_Pvariable", - return_value=mock_var, - ) as mock_new_pvar, - patch("esphome.components.motion.cg.add") as mock_add, - ): - yield {"new_Pvariable": mock_new_pvar, "add": mock_add, "var": mock_var} - - -@pytest.mark.asyncio -async def test_clear_action_without_save(mock_clear_codegen): - """With save=False, set_save should not be emitted.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: False} - result = await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - assert result is mock_clear_codegen["var"] - mock_clear_codegen["add"].assert_not_called() - - -@pytest.mark.asyncio -async def test_clear_action_with_save(mock_clear_codegen): - """With save=True, set_save(True) should be emitted exactly once.""" - config = {CONF_ID: MagicMock(), CONF_SAVE: True} - await clear_calibration_to_code(config, MagicMock(), MagicMock(), []) - mock_clear_codegen["var"].set_save.assert_called_once_with(True) - mock_clear_codegen["add"].assert_called_once() - - # --- Calibration persistence invalidation --- # # The C++ side stores a hash of the build-time base matrix alongside the saved diff --git a/tests/components/qmi8658/common-actions.yaml b/tests/components/qmi8658/common-actions.yaml new file mode 100644 index 00000000000..b8ab845ef11 --- /dev/null +++ b/tests/components/qmi8658/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Clear motion calibration + on_press: + - motion.clear_calibration: + - motion.clear_calibration: + save: true diff --git a/tests/components/qmi8658/test-actions.esp8266-ard.yaml b/tests/components/qmi8658/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..28421ac7e69 --- /dev/null +++ b/tests/components/qmi8658/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + qmi8658: !include common.yaml + actions: !include common-actions.yaml From c8d3f437127b9e54682c50957a1821743b4a36a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:17 +0100 Subject: [PATCH 549/759] [dfplayer] Use register_apply_condition for dfplayer.is_playing (#19701) --- esphome/components/dfplayer/__init__.py | 7 ++----- esphome/components/dfplayer/dfplayer.h | 5 ----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 0516b27ca21..296afcd3183 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -12,9 +12,6 @@ CODEOWNERS = ["@glmnet"] dfplayer_ns = cg.esphome_ns.namespace("dfplayer") DFPlayer = dfplayer_ns.class_("DFPlayer", cg.Component) -DFPlayerIsPlayingCondition = dfplayer_ns.class_( - "DFPlayerIsPlayingCondition", automation.Condition -) MULTI_CONF = True CONF_FOLDER = "folder" @@ -199,12 +196,12 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "dfplayer.is_playing", - DFPlayerIsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(DFPlayer), } ), + "is_playing()", ) diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index ad91dcfc94e..957583e4ffb 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -77,9 +77,4 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -template class DFPlayerIsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::dfplayer From d71a6e662b53ca758ddf8e7ffb7bb8888cbf2ce6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:21 +0100 Subject: [PATCH 550/759] [snapshot] Use register_apply_action for snapshot.take (#19696) --- esphome/components/snapshot/__init__.py | 23 +++++------------------ esphome/components/snapshot/snapshot.cpp | 7 +++++-- esphome/components/snapshot/snapshot.h | 24 ++++-------------------- 3 files changed, 14 insertions(+), 40 deletions(-) diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py index bf561a0e0da..068af830aad 100644 --- a/esphome/components/snapshot/__init__.py +++ b/esphome/components/snapshot/__init__.py @@ -12,9 +12,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -24,31 +24,18 @@ CONF_FILENAME = "filename" snapshot_ns = cg.esphome_ns.namespace("snapshot") Snapshot = snapshot_ns.class_("Snapshot") -SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action) -@automation.register_action( +automation.register_apply_action( "snapshot.take", - SnapshotAction, automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(Snapshot), - cv.Optional(CONF_FILENAME): cv.templatable(cv.string), + cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), } ), - synchronous=True, + automation.ApplyField(CONF_FILENAME, "take_snapshot_or_log", cg.std_string), ) -async def snapshot_take_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 (filename := config.get(CONF_FILENAME)) is not None: - cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string))) - return var @dataclass diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 5aa0bcd0e13..1bf63f80b00 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -192,8 +192,11 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro } // namespace -// helper function since ESP_LOGW is disallowed in a header file -void Snapshot::log_action_failed() { ESP_LOGW(TAG, "snapshot.take did not write a file"); } +void Snapshot::take_snapshot_or_log(const char *filename) { + if (!this->take_snapshot(filename[0] != '\0' ? filename : nullptr)) { + ESP_LOGW(TAG, "snapshot.take did not write a file"); + } +} bool Snapshot::take_snapshot(const char *filename) { const int width = this->snapshot_width(); diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h index bb670e639f0..359d16536d8 100644 --- a/esphome/components/snapshot/snapshot.h +++ b/esphome/components/snapshot/snapshot.h @@ -1,7 +1,6 @@ #pragma once #ifdef USE_HOST -#include "esphome/core/automation.h" #include #include @@ -34,8 +33,10 @@ class Snapshot { /// already there is never written over. Returns true if a file was written. bool take_snapshot(const char *filename); - /// Log that an action-triggered snapshot did not write a file. - static void log_action_failed(); + /// Take a snapshot for an automation: an empty name means a generated one, and a snapshot + /// that wrote nothing is logged. + void take_snapshot_or_log(const char *filename); + void take_snapshot_or_log(const std::string &filename) { this->take_snapshot_or_log(filename.c_str()); } protected: /// Width of the picture in pixels. @@ -50,23 +51,6 @@ class Snapshot { const char *snapshot_prefix_{"snapshot"}; }; -template class SnapshotAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, filename) - - protected: - void play(const Ts &...x) override { - bool ok; - if (this->filename_.has_value()) { - ok = this->parent_->take_snapshot(this->filename_.value(x...).c_str()); - } else { - ok = this->parent_->take_snapshot(nullptr); - } - if (!ok) - this->parent_->log_action_failed(); - } -}; - } // namespace esphome::snapshot #endif From 0b5a1748f7d143fdce636aa5794db04028d457b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:25 +0100 Subject: [PATCH 551/759] [tinyusb] Use register_apply_condition for tinyusb.is_mounted (#19702) --- esphome/components/tinyusb/__init__.py | 5 ++--- esphome/components/tinyusb/tinyusb_component.h | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 422a9942380..1b819088cad 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -36,7 +36,6 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) -IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( @@ -134,8 +133,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -automation.register_simple_condition( +automation.register_apply_condition( "tinyusb.is_mounted", - IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", ) diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index f7f574ec6d5..030356bc03e 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,7 +1,6 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -87,15 +86,6 @@ class TinyUSB final : public Component { }; }; -template class IsMountedCondition final : public Condition { - public: - explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_mounted(); } - - protected: - TinyUSB *parent_; -}; - } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 From 0569a1262ff52316740d66e22cad5307cebf8901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:36 +0100 Subject: [PATCH 552/759] [voice_assistant] Use register_apply_condition for is_running and connected (#19703) --- esphome/components/voice_assistant/__init__.py | 15 ++++----------- .../components/voice_assistant/voice_assistant.h | 12 ++---------- .../voice_assistant/common-actions.yaml | 14 ++++++++++++++ .../voice_assistant/test-actions.esp32-idf.yaml | 9 +++++++++ 4 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 tests/components/voice_assistant/common-actions.yaml create mode 100644 tests/components/voice_assistant/test-actions.esp32-idf.yaml diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 2f193ebb4e5..14c036de8ee 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -59,12 +59,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -IsRunningCondition = voice_assistant_ns.class_( - "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) -ConnectedCondition = voice_assistant_ns.class_( - "ConnectedCondition", automation.Condition, cg.Parented.template(VoiceAssistant) -) Timer = voice_assistant_ns.struct("Timer") @@ -421,15 +415,14 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "voice_assistant.is_running", - IsRunningCondition, VOICE_ASSISTANT_ACTION_SCHEMA, + "is_running_or_continuous()", ) - -automation.register_parented_condition( +automation.register_apply_condition( "voice_assistant.connected", - ConnectedCondition, VOICE_ASSISTANT_ACTION_SCHEMA, + "get_api_connection() != nullptr", ) diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 88ddb2066a0..e99ee07099f 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -182,6 +182,8 @@ class VoiceAssistant final : public Component { bool is_running() const { return this->state_ != State::IDLE; } void set_continuous(bool continuous) { this->continuous_ = continuous; } bool is_continuous() const { return this->continuous_; } + /// The voice_assistant.is_running condition: a pipeline is running or continuous mode keeps one coming. + bool is_running_or_continuous() const { return this->is_running() || this->is_continuous(); } void set_use_wake_word(bool use_wake_word) { this->use_wake_word_ = use_wake_word; } @@ -347,16 +349,6 @@ class VoiceAssistant final : public Component { #endif }; -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } -}; - -template class ConnectedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } -}; - extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::voice_assistant diff --git a/tests/components/voice_assistant/common-actions.yaml b/tests/components/voice_assistant/common-actions.yaml new file mode 100644 index 00000000000..e529b62ca7d --- /dev/null +++ b/tests/components/voice_assistant/common-actions.yaml @@ -0,0 +1,14 @@ +button: + - platform: template + name: Voice assistant state + on_press: + - if: + condition: + voice_assistant.is_running: + then: + - logger.log: Running + - if: + condition: + voice_assistant.connected: + then: + - logger.log: Connected diff --git a/tests/components/voice_assistant/test-actions.esp32-idf.yaml b/tests/components/voice_assistant/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..7cd9a5c3553 --- /dev/null +++ b/tests/components/voice_assistant/test-actions.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_din_pin: GPIO34 + i2s_din_pin2: GPIO35 + i2s_dout_pin: GPIO32 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + voice_assistant: !include common-idf.yaml + actions: !include common-actions.yaml From 1a158e5ba01b0d3a585673e7c000dd4ef7ccffe5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:47 +0100 Subject: [PATCH 553/759] [mitsubishi_cn105] Use register_apply_action for set_remote_temperature and clear_remote_temperature (#19706) --- .../components/mitsubishi_cn105/__init__.py | 37 +------- .../components/mitsubishi_cn105/automation.h | 23 ----- .../components/mitsubishi_cn105/climate.py | 93 +++++++------------ .../mitsubishi_cn105_climate.h | 17 ---- tests/components/mitsubishi_cn105/common.h | 1 - 5 files changed, 36 insertions(+), 135 deletions(-) delete mode 100644 esphome/components/mitsubishi_cn105/automation.h diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 8f1c6ef648b..442869eec53 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -10,9 +10,8 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_USE_FAHRENHEIT, ) -from esphome.core import ID from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@crnjan"] DEPENDENCIES = ["uart"] @@ -46,18 +45,6 @@ VERTICAL_VANE_DIRECTIONS = { "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, } -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Component), -) - CONFIG_SCHEMA = ( cv.Schema( @@ -143,30 +130,16 @@ CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +automation.register_apply_action( f"{DOMAIN}.set_remote_temperature", - SetRemoteTemperatureAction, REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def remote_temperature_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]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -automation.register_parented_action( +automation.register_apply_action( f"{DOMAIN}.clear_remote_temperature", - ClearRemoteTemperatureAction, CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("clear_remote_temperature()"), ) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h deleted file mode 100644 index f0bd771fd5c..00000000000 --- a/esphome/components/mitsubishi_cn105/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "mitsubishi_cn105_component.h" - -#include "esphome/core/automation.h" - -namespace esphome::mitsubishi_cn105 { - -template -class SetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -template -class ClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - -} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index 05a29b36654..756c8142c20 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import logging from esphome import automation @@ -12,10 +13,9 @@ from esphome.const import ( CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj +from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import ( CONF_MITSUBISHI_CN105_ID, @@ -44,20 +44,6 @@ MitsubishiCN105Climate = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacySetRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - -# Legacy climate action compatibility. Remove in 2027.2.0. -LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( - "LegacyClearRemoteTemperatureAction", - automation.Action, - cg.Parented.template(MitsubishiCN105Climate), -) - # Legacy climate-owned hub compatibility. Remove in 2027.2.0. def _has_top_level_hub_config() -> bool: @@ -204,53 +190,36 @@ LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( ) -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +def _warn_legacy_action(name: str) -> Callable[[ConfigType], ConfigType]: + # Legacy climate action compatibility. Remove in 2027.2.0. + def validator(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "The 'climate.%s.%s' action is deprecated. Use '%s.%s' instead. " + "It will be removed in ESPHome 2027.2.0.", + DOMAIN, + name, + DOMAIN, + name, + ) + return config + + return validator + + +automation.register_apply_action( f"climate.{DOMAIN}.set_remote_temperature", - LegacySetRemoteTemperatureAction, - LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("set_remote_temperature"), + ), + automation.ApplyField(CONF_TEMPERATURE, "set_remote_temperature", cg.float_), ) -async def legacy_remote_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.set_remote_temperature' action is deprecated. Use " - "'%s.set_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) - cg.add(var.set_temperature(temperature)) - return var - -# Legacy climate action compatibility. Remove in 2027.2.0. -@automation.register_action( +automation.register_apply_action( f"climate.{DOMAIN}.clear_remote_temperature", - LegacyClearRemoteTemperatureAction, - LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, - synchronous=True, + cv.All( + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + _warn_legacy_action("clear_remote_temperature"), + ), + automation.ApplyCall("clear_remote_temperature()"), ) -async def legacy_clear_temperature_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - _LOGGER.warning( - "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " - "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " - "2027.2.0.", - DOMAIN, - DOMAIN, - ) - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index cea76278abd..fc665b079af 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -3,7 +3,6 @@ #include "mitsubishi_cn105_component.h" #include "mitsubishi_cn105.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" #include "mitsubishi_cn105_swing_mode_manager.h" @@ -31,20 +30,4 @@ class MitsubishiCN105Climate final : public climate::Climate, SwingModeManager swing_mode_manager_; }; -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacySetRemoteTemperatureAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, temperature) - - void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } -}; - -// Legacy climate action compatibility. Remove in 2027.2.0. -template -class LegacyClearRemoteTemperatureAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } -}; - } // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 6029327e9ab..b643500234d 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" -#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" From 2fc48a3d9e28c80d872b65fab77fff1074a28de8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:50 +0100 Subject: [PATCH 554/759] [sun] Use register_apply_condition for is_above_horizon and is_below_horizon (#19704) --- esphome/components/sun/__init__.py | 63 +++++-------------- esphome/components/sun/sun.h | 19 ------ tests/components/sun/common-actions.yaml | 21 +++++++ .../sun/test-actions.esp8266-ard.yaml | 3 + 4 files changed, 39 insertions(+), 67 deletions(-) create mode 100644 tests/components/sun/common-actions.yaml create mode 100644 tests/components/sun/test-actions.esp8266-ard.yaml diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 33a5c677bdf..73ddefe9729 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -13,8 +13,6 @@ 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"] @@ -24,7 +22,6 @@ Sun = sun_ns.class_("Sun") SunTrigger = sun_ns.class_( "SunTrigger", cg.PollingComponent, automation.Trigger.template() ) -SunCondition = sun_ns.class_("SunCondition", automation.Condition) CONF_SUN_ID = "sun_id" CONF_ELEVATION = "elevation" @@ -142,53 +139,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) -@automation.register_condition( +SUN_HORIZON_CONDITION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Sun), + cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( + elevation + ), + } +) + +automation.register_apply_condition( "sun.is_above_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() > {}", ((CONF_ELEVATION, cg.double),)), ) -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) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(True)) - return var - -@automation.register_condition( +automation.register_apply_condition( "sun.is_below_horizon", - SunCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(Sun), - cv.Optional(CONF_ELEVATION, default=DEFAULT_ELEVATION): cv.templatable( - elevation - ), - } - ), + SUN_HORIZON_CONDITION_SCHEMA, + automation.ApplyCall("elevation() < {}", ((CONF_ELEVATION, cg.double),)), ) -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) - cg.add(var.set_elevation(templ)) - cg.add(var.set_above(False)) - return var diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index ea9e05042d9..af2af15dd29 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -109,23 +109,4 @@ class SunTrigger final : public Trigger<>, public PollingComponent, public Paren double elevation_; }; -template class SunCondition final : public Condition, public Parented { - public: - TEMPLATABLE_VALUE(double, elevation); - void set_above(bool above) { above_ = above; } - - bool check(const Ts &...x) override { - double elevation = this->elevation_.value(x...); - double current = this->parent_->elevation(); - if (this->above_) { - return current > elevation; - } else { - return current < elevation; - } - } - - protected: - bool above_; -}; - } // namespace esphome::sun diff --git a/tests/components/sun/common-actions.yaml b/tests/components/sun/common-actions.yaml new file mode 100644 index 00000000000..460be698791 --- /dev/null +++ b/tests/components/sun/common-actions.yaml @@ -0,0 +1,21 @@ +button: + - platform: template + name: Sun position + on_press: + - if: + condition: + sun.is_above_horizon: + then: + - logger.log: Day + - if: + condition: + sun.is_below_horizon: + elevation: -6° + then: + - logger.log: Night + - if: + condition: + sun.is_above_horizon: + elevation: !lambda return 10.0; + then: + - logger.log: High sun diff --git a/tests/components/sun/test-actions.esp8266-ard.yaml b/tests/components/sun/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..82c874ac33c --- /dev/null +++ b/tests/components/sun/test-actions.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + sun: !include common.yaml + actions: !include common-actions.yaml From 93fabe8b84f2c959426ae0067364d1c63a465d73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:33:57 +0100 Subject: [PATCH 555/759] [alarm_control_panel] Use register_apply_condition for is_armed and ready (#19707) --- .../alarm_control_panel/__init__.py | 20 ++++--------------- .../alarm_control_panel/alarm_control_panel.h | 6 ++++++ .../alarm_control_panel/automation.h | 12 ----------- .../alarm_control_panel/common-actions.yaml | 10 ++++++++++ 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 37b27b5391e..78206ff3f8a 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -42,10 +42,6 @@ StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -AlarmControlPanelCondition = alarm_control_panel_ns.class_( - "AlarmControlPanelCondition", automation.Condition -) - _ALARM_CONTROL_PANEL_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -216,18 +212,10 @@ for _name, _call in ( ) -automation.register_simple_condition( - "alarm_control_panel.ready", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) - - -automation.register_simple_condition( - "alarm_control_panel.is_armed", - AlarmControlPanelCondition, - ALARM_CONTROL_PANEL_CONDITION_SCHEMA, -) +for _name in ("alarm_control_panel.ready", "alarm_control_panel.is_armed"): + automation.register_apply_condition( + _name, ALARM_CONTROL_PANEL_CONDITION_SCHEMA, "is_armed_pending_or_triggered()" + ) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index aced89b7ffc..1215044ca10 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -130,6 +130,12 @@ class AlarmControlPanel : public EntityBase { // is the state one of the armed states bool is_state_armed(AlarmControlPanelState state); + /// Armed, pending (entry delay) or triggered; not ARMING (exit delay). Backs the is_armed and ready + /// conditions. + bool is_armed_pending_or_triggered() { + auto state = this->get_state(); + return this->is_state_armed(state) || state == ACP_STATE_PENDING || state == ACP_STATE_TRIGGERED; + } protected: friend AlarmControlPanelCall; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 7a683086a46..86bdac37ec8 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,16 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class AlarmControlPanelCondition final : public Condition { - public: - AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} - bool check(const Ts &...x) override { - return this->parent_->is_state_armed(this->parent_->get_state()) || - this->parent_->get_state() == ACP_STATE_PENDING || this->parent_->get_state() == ACP_STATE_TRIGGERED; - } - - protected: - AlarmControlPanel *parent_; -}; - } // namespace esphome::alarm_control_panel diff --git a/tests/components/alarm_control_panel/common-actions.yaml b/tests/components/alarm_control_panel/common-actions.yaml index 1ba7342c891..d757fa631a3 100644 --- a/tests/components/alarm_control_panel/common-actions.yaml +++ b/tests/components/alarm_control_panel/common-actions.yaml @@ -4,3 +4,13 @@ button: on_press: - alarm_control_panel.pending: alarmcontrolpanel1 - alarm_control_panel.triggered: alarmcontrolpanel1 + - if: + condition: + alarm_control_panel.is_armed: alarmcontrolpanel1 + then: + - logger.log: Armed + - if: + condition: + alarm_control_panel.ready: alarmcontrolpanel1 + then: + - logger.log: Ready From a1d8ea6072556dc8ed27eb9a5c0b11d260679cf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:06 +0100 Subject: [PATCH 556/759] [aic3204] Use register_apply_action for aic3204.set_auto_mute_mode (#19684) --- esphome/components/aic3204/audio_dac.py | 21 ++----------------- esphome/components/aic3204/automation.h | 21 ------------------- tests/components/aic3204/common-actions.yaml | 8 +++++++ .../aic3204/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 14 insertions(+), 40 deletions(-) delete mode 100644 esphome/components/aic3204/automation.h create mode 100644 tests/components/aic3204/common-actions.yaml create mode 100644 tests/components/aic3204/test-actions.esp8266-ard.yaml diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index 50e2f81f1b6..b4942fe3908 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,8 +4,6 @@ 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"] @@ -14,7 +12,6 @@ DEPENDENCIES = ["i2c"] aic3204_ns = cg.esphome_ns.namespace("aic3204") AIC3204 = aic3204_ns.class_("AIC3204", AudioDac, cg.Component, i2c.I2CDevice) -SetAutoMuteAction = aic3204_ns.class_("SetAutoMuteAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -36,25 +33,11 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action( +automation.register_apply_action( "aic3204.set_auto_mute_mode", - SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_MODE, "set_auto_mute_mode", cg.uint8), ) -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) - - template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8) - cg.add(var.set_auto_mute_mode(template_)) - - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h deleted file mode 100644 index f0f88566145..00000000000 --- a/esphome/components/aic3204/automation.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "aic3204.h" - -namespace esphome::aic3204 { - -template class SetAutoMuteAction final : public Action { - public: - explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} - - TEMPLATABLE_VALUE(uint8_t, auto_mute_mode) - - void play(const Ts &...x) override { this->aic3204_->set_auto_mute_mode(this->auto_mute_mode_.value(x...)); } - - protected: - AIC3204 *aic3204_; -}; - -} // namespace esphome::aic3204 diff --git a/tests/components/aic3204/common-actions.yaml b/tests/components/aic3204/common-actions.yaml new file mode 100644 index 00000000000..7981fcc1b07 --- /dev/null +++ b/tests/components/aic3204/common-actions.yaml @@ -0,0 +1,8 @@ +button: + - platform: template + name: AIC3204 Auto Mute + on_press: + - aic3204.set_auto_mute_mode: 3 + - aic3204.set_auto_mute_mode: + id: aic3204_dac + mode: !lambda return 7; diff --git a/tests/components/aic3204/test-actions.esp8266-ard.yaml b/tests/components/aic3204/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..c5431f65a3e --- /dev/null +++ b/tests/components/aic3204/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + aic3204: !include common.yaml + actions: !include common-actions.yaml From 0b2e5bfbbe3e5711c622527b41d285953355029d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:16 +0100 Subject: [PATCH 557/759] [pid] Use register_apply_action for climate.pid.autotune (#19708) --- esphome/components/pid/climate.py | 28 +++++++++----------------- esphome/components/pid/pid_climate.cpp | 8 ++++++++ esphome/components/pid/pid_climate.h | 25 ++--------------------- 3 files changed, 20 insertions(+), 41 deletions(-) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 11a6fd5e6bd..1527fa6f354 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,13 +3,11 @@ 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, Lambda -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import Lambda from esphome.types import ConfigType pid_ns = cg.esphome_ns.namespace("pid") PIDClimate = pid_ns.class_("PIDClimate", climate.Climate, cg.Component) -PIDAutotuneAction = pid_ns.class_("PIDAutotuneAction", automation.Action) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -158,9 +156,8 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_apply_action( "climate.pid.autotune", - PIDAutotuneAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -173,20 +170,15 @@ automation.register_apply_action( ): cv.possibly_negative_percentage, } ), - synchronous=True, + automation.ApplyCall( + "start_autotune({}, {}, {})", + ( + (CONF_NOISEBAND, cg.float_), + (CONF_POSITIVE_OUTPUT, cg.float_), + (CONF_NEGATIVE_OUTPUT, cg.float_), + ), + ), ) -async def esp8266_set_frequency_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_noiseband(config[CONF_NOISEBAND])) - cg.add(var.set_positive_output(config[CONF_POSITIVE_OUTPUT])) - cg.add(var.set_negative_output(config[CONF_NEGATIVE_OUTPUT])) - return var automation.register_apply_action( diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index b386716780e..1f0d45b0847 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -169,6 +169,14 @@ void PIDClimate::update_pid_() { if (this->do_publish_) this->publish_state(); } +void PIDClimate::start_autotune(float noiseband, float positive_output, float negative_output) { + auto tuner = make_unique(); + tuner->set_noiseband(noiseband); + tuner->set_output_positive(positive_output); + tuner->set_output_negative(negative_output); + this->start_autotune(std::move(tuner)); +} + void PIDClimate::start_autotune(std::unique_ptr &&autotune) { this->autotuner_ = std::move(autotune); float min_value = this->supports_cool_() ? -1.0f : 0.0f; diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index e8701c99a49..6fea2bedf30 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -79,6 +79,8 @@ class PIDClimate final : public climate::Climate, public Component { default_target_temperature_ = default_target_temperature; } void start_autotune(std::unique_ptr &&autotune); + /// Build a tuner from the climate.pid.autotune settings and start it. + void start_autotune(float noiseband, float positive_output, float negative_output); void reset_integral_term(); protected: @@ -109,27 +111,4 @@ class PIDClimate final : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction final : public Action { - public: - PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} - - void set_noiseband(float noiseband) { noiseband_ = noiseband; } - void set_positive_output(float positive_output) { positive_output_ = positive_output; } - void set_negative_output(float negative_output) { negative_output_ = negative_output; } - - void play(const Ts &...x) { - auto tuner = make_unique(); - tuner->set_noiseband(this->noiseband_); - tuner->set_output_negative(this->negative_output_); - tuner->set_output_positive(this->positive_output_); - this->parent_->start_autotune(std::move(tuner)); - } - - protected: - float noiseband_; - float positive_output_; - float negative_output_; - PIDClimate *parent_; -}; - } // namespace esphome::pid From b8c32854edf2e36849b3ba5cd0366048149c1802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:22 +0100 Subject: [PATCH 558/759] [bl0906] Use register_apply_action for bl0906.reset_energy (#19688) --- esphome/components/bl0906/bl0906.h | 13 +++---------- esphome/components/bl0906/sensor.py | 6 ++---- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 54de9f9b0cc..93ccf09521b 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -2,7 +2,6 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/datatypes.h" @@ -47,8 +46,6 @@ struct sbe24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-ali int8_t h{0}; } __attribute__((packed)); -template class ResetEnergyAction; - class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); @@ -85,9 +82,10 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { void setup() override; void dump_config() override; - protected: - template friend class ResetEnergyAction; + /// Queue an energy counter reset for the next poll + void reset_energy() { this->enqueue_action_(&BL0906::reset_energy_); } + protected: void reset_energy_(); void read_data_(uint8_t address, float reference, sensor::Sensor *sensor); @@ -103,9 +101,4 @@ class BL0906 final : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } -}; - } // namespace esphome::bl0906 diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index e24cd316f4e..67ff903fbf1 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -43,7 +43,6 @@ CONF_TOTAL_ENERGY = "total_energy" bl0906_ns = cg.esphome_ns.namespace("bl0906") BL0906 = bl0906_ns.class_("BL0906", cg.PollingComponent, uart.UARTDevice) -ResetEnergyAction = bl0906_ns.class_("ResetEnergyAction", automation.Action) CONFIG_SCHEMA = ( cv.Schema( @@ -136,15 +135,14 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -automation.register_parented_action( +automation.register_apply_action( "bl0906.reset_energy", - ResetEnergyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BL0906), } ), - synchronous=True, + automation.ApplyCall("reset_energy()"), ) From 3f2de92e5d842f1030d32c2f398174e921d3cea4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:38 +0100 Subject: [PATCH 559/759] [openthread] Use register_apply_action for openthread.set_poll_period (#19697) --- esphome/components/openthread/__init__.py | 24 +------- esphome/components/openthread/automation.cpp | 37 ------------ esphome/components/openthread/automation.h | 61 -------------------- esphome/components/openthread/openthread.cpp | 19 ++++++ esphome/components/openthread/openthread.h | 8 +-- 5 files changed, 23 insertions(+), 126 deletions(-) delete mode 100644 esphome/components/openthread/automation.cpp delete mode 100644 esphome/components/openthread/automation.h diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index a71151f3ffb..907200d5ec2 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -35,12 +35,10 @@ 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 @@ -328,12 +326,6 @@ async def to_code(config: ConfigType) -> None: # Actions -OpenThreadComponentPollPeriodAction = openthread_ns.class_( - "OpenThreadComponentPollPeriodAction", - automation.Action, - cg.Parented.template(OpenThreadComponent), -) - POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( CONF_POLL_PERIOD, cv.Schema( @@ -347,20 +339,8 @@ POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( ) -@automation.register_action( +automation.register_apply_action( "openthread.set_poll_period", - OpenThreadComponentPollPeriodAction, POLL_PERIOD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyField(CONF_POLL_PERIOD, "apply_poll_period", cg.uint32), ) -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) - cg.add(var.set_poll_period(template_)) - return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp deleted file mode 100644 index 770bf124c55..00000000000 --- a/esphome/components/openthread/automation.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "esphome/core/defines.h" - -#ifdef USE_OPENTHREAD - -#include "automation.h" -#include "esphome/core/log.h" - -namespace esphome::openthread { - -static const char *const TAG = "openthread.automation"; - -void OpenThreadComponentBaseAction::warn_ftd_no_op_() { - ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); -} - -void OpenThreadComponentBaseAction::lock_and_apply_() { - if (this->parent_->is_ready()) { - if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { - if (auto *instance = lock.get_instance(); instance != nullptr) { - this->apply_locked(instance); - } - } else { - ESP_LOGW(TAG, "Failed to acquire lock in action"); - } - } else { - // Action may trigger early before setup, e.g. due to enabled "restore mode". - // Trying to acquire lock would fail! - // - // But default component values already have been overwritten. - // It is sufficient to let component apply those later during setup. - ESP_LOGD(TAG, "Not (yet) ready to apply"); - } -} - -} // namespace esphome::openthread - -#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h deleted file mode 100644 index 3706499fda0..00000000000 --- a/esphome/components/openthread/automation.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include "esphome/core/defines.h" -#ifdef USE_OPENTHREAD -#include "openthread.h" - -#include "esphome/core/automation.h" -#include "esphome/core/helpers.h" - -namespace esphome::openthread { - -/** Base class allowing to fetch OpenThread lock from parent component - * while applying action - * - * - Nontemplate aspects belong here to avoid template bloat. - * - Subclasses implement virtual action method that is called under lock. - * - Seal leaf subclasses via @a final to support devirtualization. - */ -class OpenThreadComponentBaseAction : public Parented { - public: - // Enforce ctor with parent argument (not without args) - explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} - - protected: - /** Handler to implement in subclass for applying action parts that need lock */ - virtual void apply_locked(otInstance *instance) = 0; - - /** Fetch OT lock and then call @a apply_locked */ - void lock_and_apply_(); - - /** Log a warning that this action has no effect on FTD devices */ - void warn_ftd_no_op_(); - - /** Timeout (ms) for acquiring OT lock */ - static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; -}; - -/** Action to set single poll period parameter */ -template -class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { - TEMPLATABLE_VALUE(uint32_t, poll_period) - - public: - /* Passthrough ctor */ - using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; - - protected: - void play(const Ts &...x) override { -#if CONFIG_OPENTHREAD_MTD - this->parent_->set_poll_period(this->poll_period_.value(x...)); - - this->lock_and_apply_(); -#else - this->warn_ftd_no_op_(); -#endif - } - - void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } -}; - -} // namespace esphome::openthread -#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index ae896fcfeea..c788b1f968d 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -282,6 +282,25 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { +#if CONFIG_OPENTHREAD_MTD + this->set_poll_period(poll_period); + if (!this->is_lock_initialized()) { + // The action may run before the stack is up, e.g. from a restore mode; ot_main applies the stored value. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + return; + } + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + return; + } + this->apply_linkmode_(lock.get_instance()); +#else + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +#endif +} + void OpenThreadComponent::apply_linkmode_(otInstance *instance) { otLinkModeConfig link_mode_config{}; #if CONFIG_OPENTHREAD_FTD diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b83ffdb6af6..8bccae7f85e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -25,8 +25,6 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; -template class OpenThreadComponentPollPeriodAction; - class OpenThreadComponent final : public Component { public: OpenThreadComponent(); @@ -53,15 +51,13 @@ class OpenThreadComponent final : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } uint32_t get_poll_period() const { return this->poll_period_; } #endif + /// Set the poll period and re-apply the link mode under the OT lock; a warning only on FTD builds. + void apply_poll_period(uint32_t poll_period); void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); protected: - // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() - // without exposing this lock-sensitive, raw-instance method on the public API. - template friend class OpenThreadComponentPollPeriodAction; - /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. * ot_main() runs on the OpenThread task itself and must not acquire the lock. From a35299af0b73c186ca4ae375281e20156c7c02e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:34:52 +0100 Subject: [PATCH 560/759] [emontx] Use register_apply_action for emontx.send_command (#19687) --- esphome/components/emontx/__init__.py | 30 +++++++------------ esphome/components/emontx/emontx.cpp | 6 ++-- esphome/components/emontx/emontx.h | 12 ++------ tests/components/emontx/common-actions.yaml | 7 +++++ .../emontx/test-actions.esp8266-ard.yaml | 4 +++ 5 files changed, 27 insertions(+), 32 deletions(-) create mode 100644 tests/components/emontx/common-actions.yaml create mode 100644 tests/components/emontx/test-actions.esp8266-ard.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3821f3e10e2..7eae375cd6f 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,8 +11,7 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -23,9 +22,6 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) -# Action to send command to emonTx -EmonTxSendCommandAction = emontx_ns.class_("EmonTxSendCommandAction", automation.Action) - CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" @@ -139,20 +135,16 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( +def _plain_literal(config: ConfigType, value: str) -> str: + return str(cg.safe_exp(value)) + + +automation.register_apply_action( "emontx.send_command", - EmonTxSendCommandAction, EMONTX_SEND_COMMAND_ACTION_SCHEMA, - synchronous=True, + # A constant is a plain literal for the const char * overload; a lambda returns a + # std::string and takes the inline overload. + automation.ApplyField( + CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal + ), ) -async def emontx_send_command_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]) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) - cg.add(var.set_command(template_)) - return var diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 7a1b084fe08..8efc2de7538 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -94,9 +94,9 @@ void EmonTx::dump_config() { * * @param command The command string to send (LF will be appended automatically). */ -void EmonTx::send_command(const std::string &command) { - ESP_LOGD(TAG, "Sending command to emonTx: %s", command.c_str()); - this->write_str(command.c_str()); +void EmonTx::send_command(const char *command) { + ESP_LOGD(TAG, "Sending command to emonTx: %s", command); + this->write_str(command); this->write_byte('\n'); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 6db197a78c5..c56005e7ad8 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -2,7 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/automation.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" #include "esphome/components/uart/uart.h" @@ -39,7 +38,8 @@ class EmonTx final : public Component, public uart::UARTDevice { template void add_on_data_callback(F &&callback) { this->data_callbacks_.add(std::forward(callback)); } // Send command to emonTx via UART - void send_command(const std::string &command); + void send_command(const char *command); + void send_command(const std::string &command) { this->send_command(command.c_str()); } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -58,12 +58,4 @@ class EmonTx final : public Component, public uart::UARTDevice { std::array buffer_{}; }; -// Action to send command to emonTx -template class EmonTxSendCommandAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(std::string, command) - - void play(const Ts &...x) override { this->parent_->send_command(this->command_.value(x...)); } -}; - } // namespace esphome::emontx diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml new file mode 100644 index 00000000000..af8d44896be --- /dev/null +++ b/tests/components/emontx/common-actions.yaml @@ -0,0 +1,7 @@ +button: + - platform: template + name: Send lambda command + on_press: + - emontx.send_command: + id: test_emontx + command: !lambda return "l"; diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..4be79a40dac --- /dev/null +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + emontx: !include common.yaml + actions: !include common-actions.yaml From a61746045b4af4ec817a269e08085b97be295763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 01:51:49 +0100 Subject: [PATCH 561/759] [core] Pass the copy flag to JsonString explicitly in the StringRef conversion (#19709) --- esphome/core/string_ref.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index da071287048..4c0ce28a5ac 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -279,7 +279,9 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(""); return; } - dst.set(JsonString(src.c_str(), src.size())); + // The explicit isStatic is load bearing: without it clang-tidy reads size() as the bool of the two argument + // constructor and asks for empty() instead + dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } #endif // USE_JSON From 2dc7a64e526732ca11cfc7102a554c4b4e35e2ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:10:10 +0100 Subject: [PATCH 562/759] [it8951] Use register_apply_action for it8951.update (#19691) --- esphome/components/it8951/display.py | 24 ++++--------------- esphome/components/it8951/it8951.cpp | 6 ++--- esphome/components/it8951/it8951.h | 21 ---------------- .../components/it8951/test.esp32-s3-idf.yaml | 1 + 4 files changed, 9 insertions(+), 43 deletions(-) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 57bf86c4c6c..b5f3019a126 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -36,8 +36,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.cpp_generator import RawExpression from esphome.final_validate import full_config from esphome.types import ConfigType @@ -61,7 +60,6 @@ VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) it8951_ns = cg.esphome_ns.namespace("it8951") IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) -IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) # Hardware waveform modes exposed to YAML. Strings are mapped to the C++ # UpdateMode enum so the runtime can store the mode as a uint16_t rather @@ -80,6 +78,7 @@ UPDATE_MODE_OPTIONS = { "A2": UpdateMode.UPDATE_MODE_A2, "FAST": UpdateMode.UPDATE_MODE_DU, "FULL": UpdateMode.UPDATE_MODE_GC16, + "DEFAULT": UpdateMode.UPDATE_MODE_NONE, } # Maps the YAML mode string directly to the C++ UpdateMode enum value, so the # config option and the it8951.update action share one validator. @@ -419,26 +418,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transform(RawExpression(str(transform_value)))) -@automation.register_action( +automation.register_apply_action( "it8951.update", - IT8951UpdateAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IT8951Display), - cv.Optional(CONF_MODE): cv.templatable(update_mode), + cv.Optional(CONF_MODE, default="DEFAULT"): cv.templatable(update_mode), } ), - synchronous=True, + automation.ApplyField(CONF_MODE, "update_mode", UpdateMode), ) -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): - mode = await cg.templatable(mode, args, UpdateMode) - cg.add(var.set_mode(mode)) - return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 237f1c3c8bd..a6511b63c41 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -777,12 +777,12 @@ void IT8951Display::update() { } void IT8951Display::update_mode(UpdateMode mode) { - if (!this->is_ready()) - return; if (mode == UPDATE_MODE_NONE) { - ESP_LOGW(TAG, "Unknown update mode"); + this->update(); return; } + if (!this->is_ready()) + return; this->start_update_(mode); } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index a5ed03e8c4b..fcdc6d1d196 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -6,7 +6,6 @@ #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -350,24 +349,4 @@ class IT8951Display : public Display, uint8_t dev_info_attempts_{0}; }; -// --- Automation action --- -template class IT8951UpdateAction : public Action { - public: - explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} - TEMPLATABLE_VALUE(UpdateMode, mode) - - protected: - void play(const Ts &...x) override { - if (!this->display_->is_ready()) - return; - if (this->mode_.has_value()) { - this->display_->update_mode(this->mode_.value(x...)); - } else { - this->display_->update(); - } - } - - IT8951Display *display_; -}; - } // namespace esphome::it8951 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml index c362f7f28c4..922e7d0ed3c 100644 --- a/tests/components/it8951/test.esp32-s3-idf.yaml +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -59,6 +59,7 @@ display: - platform: it8951 spi_id: spi_bus model: seeed-reterminal-e1003 + update_mode: DEFAULT cs_pin: allow_other_uses: true number: GPIO5 From 76de6a672c3cc6b0b8a6b6f50d6bb16b9465caa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:16:01 +0100 Subject: [PATCH 563/759] [core] Render an id constant in an apply field as the object it names (#19694) --- esphome/automation.py | 14 +++++++++----- tests/unit_tests/test_automation.py | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a67cd31baf..2f970b915be 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -239,9 +239,9 @@ class ApplyCall: Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use - ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda bypasses - it. The statement is skipped when none of its keys is set, always emitted when it has no - keys, and a partial set is a config error. + ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an + id bypasses it. The statement is skipped when none of its keys is set, always emitted when it + has no keys, and a partial set is a config error. """ target: str @@ -280,8 +280,8 @@ class ApplyField: Double a literal brace in a template. ``conf_key`` may be a path into nested sections. ``type_`` may be a C++ type string using ``{parent}`` when the type is only known per instance. ``const_fn(config, value)`` renders a constant's argument text when ``cg.safe_exp`` - is not the right spelling (unit conversion belongs in the validator); a lambda bypasses it, - so the target must also take a plain ``type_``. An absent key emits nothing. + is not the right spelling (unit conversion belongs in the validator); a lambda or an id + bypasses it, so the target must also take a plain ``type_``. An absent key emits nothing. """ conf_key: str | tuple[str, ...] @@ -380,6 +380,9 @@ async def _render_values( expr = call_lambda(inner) bare = compare and isinstance(expr, cg.RawExpression) exprs.append(f"({expr})" if bare else str(expr)) + elif isinstance(value, ID): + # Qualified like the parent, so a trigger arg named like the id cannot shadow it. + exprs.append(f"::{await cg.get_variable(value)}") elif const_fn is not None: exprs.append(const_fn(config, value)) else: @@ -402,6 +405,7 @@ def register_apply_action( Generates one stateless function for ``ApplyAction``: the parent (read from ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. """ diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 1e1d2e714ab..001756d72fc 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -705,6 +705,32 @@ async def test_apply_constants( assert "set_ki" not in text +@pytest.mark.asyncio +async def test_apply_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A templatable use_id given as a plain id renders the object it names.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),) + await _run_apply_action(registries, fields, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in _apply_lambda(mock_cg) + + +@pytest.mark.asyncio +async def test_apply_condition_id_constant_is_the_named_object( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """The condition path resolves and qualifies an id constant the same way.""" + target = MockObj("speaker_b", "->") + mock_cg.get_variable.side_effect = [PARENT_OBJ, target] + check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),)) + await _run_apply_condition(registries, check, {"target": ID("speaker_b")}) + mock_cg.get_variable.assert_any_await(ID("speaker_b")) + assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in _apply_lambda(mock_cg) + + @pytest.mark.asyncio async def test_apply_lambdas( registries: tuple[Registry, Registry], mock_cg: MockCodegen From 0d445fb3bc02f36cbe65379af90fae7ab8137f5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:16:32 +0100 Subject: [PATCH 564/759] [router] Use register_apply_action for router.speaker.switch_output (#19695) --- esphome/components/router/speaker/__init__.py | 26 +++---------------- .../router/speaker/router_speaker.h | 11 -------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index 18311416c30..f568aedcbdf 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation, core +from esphome import automation import esphome.codegen as cg from esphome.components import audio, speaker import esphome.config_validation as cv @@ -9,9 +9,7 @@ from esphome.const import ( CONF_OUTPUT_SPEAKER, CONF_SAMPLE_RATE, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] @@ -20,7 +18,6 @@ CONF_TARGET_SPEAKER = "target_speaker" router_ns = cg.esphome_ns.namespace("router") Router = router_ns.class_("Router", cg.Component, speaker.Speaker) -SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action) SpeakerPtr = speaker.Speaker.operator("ptr") @@ -93,9 +90,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_output(spk)) -@automation.register_action( +automation.register_apply_action( "router.speaker.switch_output", - SwitchOutputAction, cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(Router), @@ -104,19 +100,5 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyField(CONF_TARGET_SPEAKER, "switch_to_output", SpeakerPtr), ) -async def switch_output_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, parent) - target = config[CONF_TARGET_SPEAKER] - if not isinstance(target, core.Lambda): - target = await cg.get_variable(target) - template_ = await cg.templatable(target, args, SpeakerPtr) - cg.add(var.set_target(template_)) - return var diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 31f3f906301..2ce4c1a906d 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -3,7 +3,6 @@ #ifdef USE_ESP32 #include "esphome/components/speaker/speaker.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -80,16 +79,6 @@ class Router final : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction final : public Action { - public: - explicit SwitchOutputAction(Router *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(speaker::Speaker *, target) - void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); } - - protected: - Router *parent_; -}; - } // namespace esphome::router #endif // USE_ESP32 From d2c33b0f6f2acf561e658a9ea4616fbbadfcb46f Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:24:51 +0000 Subject: [PATCH 565/759] [systa_bus] Add systa_bus component (#15473) 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 --- CODEOWNERS | 1 + esphome/components/systa_bus/__init__.py | 41 ++++++ .../components/systa_bus/sensor/__init__.py | 81 ++++++++++++ .../systa_bus/sensor/systa_bus_sensor.cpp | 37 ++++++ .../systa_bus/sensor/systa_bus_sensor.h | 28 ++++ esphome/components/systa_bus/systa_bus.cpp | 70 ++++++++++ esphome/components/systa_bus/systa_bus.h | 50 +++++++ esphome/core/defines.h | 1 + tests/components/systa_bus/__init__.py | 13 ++ tests/components/systa_bus/common.h | 37 ++++++ tests/components/systa_bus/common.yaml | 15 +++ tests/components/systa_bus/systa_bus_test.cpp | 125 ++++++++++++++++++ .../components/systa_bus/test.esp32-idf.yaml | 3 + 13 files changed, 502 insertions(+) create mode 100644 esphome/components/systa_bus/__init__.py create mode 100644 esphome/components/systa_bus/sensor/__init__.py create mode 100644 esphome/components/systa_bus/sensor/systa_bus_sensor.cpp create mode 100644 esphome/components/systa_bus/sensor/systa_bus_sensor.h create mode 100644 esphome/components/systa_bus/systa_bus.cpp create mode 100644 esphome/components/systa_bus/systa_bus.h create mode 100644 tests/components/systa_bus/__init__.py create mode 100644 tests/components/systa_bus/common.h create mode 100644 tests/components/systa_bus/common.yaml create mode 100644 tests/components/systa_bus/systa_bus_test.cpp create mode 100644 tests/components/systa_bus/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 52fec0a7aca..cfe5638a616 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,6 +549,7 @@ esphome/components/sx126x/* @swoboda1337 esphome/components/sx127x/* @swoboda1337 esphome/components/sy6970/* @linkedupbits esphome/components/syslog/* @clydebarrow +esphome/components/systa_bus/* @Mat931 esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom esphome/components/tc74/* @sethgirvan diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py new file mode 100644 index 00000000000..0695b43d416 --- /dev/null +++ b/esphome/components/systa_bus/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@Mat931"] + +DEPENDENCIES = ["uart"] + +MULTI_CONF = True + +systa_bus_ns = cg.esphome_ns.namespace("systa_bus") +SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) + +CONF_SYSTA_BUS_ID = "systa_bus_id" + +CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaBus), + } +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "systa_bus", baud_rate=9600, require_rx=True +) + +_request_listener_slot = cg.slot_counter("SYSTA_BUS_LISTENER_COUNT") + + +async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: + """Register a listener with its bus and count it for the compile-time listener storage.""" + _request_listener_slot(str(systa_bus)) + cg.add(systa_bus.register_listener(var)) + + +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/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py new file mode 100644 index 00000000000..3def5fba12f --- /dev/null +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + DEVICE_CLASS_TEMPERATURE, + ICON_PERCENT, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns + +SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) + +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" +CONF_TEMPERATURE_TSA = "temperature_tsa" +CONF_TEMPERATURE_TSE = "temperature_tse" +CONF_TEMPERATURE_TWU = "temperature_twu" +CONF_TEMPERATURE_TW2 = "temperature_tw2" +CONF_PUMP_SPEED = "pump_speed" + +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_PUMP_SPEED_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +MODEL_SENSORS = { + CONF_SYSTASOLAR_AQUA: { + CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TSE: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TWU: _TEMPERATURE_SCHEMA, + CONF_TEMPERATURE_TW2: _TEMPERATURE_SCHEMA, + CONF_PUMP_SPEED: _PUMP_SPEED_SCHEMA, + }, +} + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + **{ + cv.Optional(key): schema + for key, schema in MODEL_SENSORS[CONF_SYSTASOLAR_AQUA].items() + }, + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + for key in MODEL_SENSORS[config[CONF_MODEL]]: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) + + systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) + await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp new file mode 100644 index 00000000000..08a46771805 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -0,0 +1,37 @@ +#include "systa_bus_sensor.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus.sensor"; + +static int16_t get_i16be(std::span message, size_t start) { + return static_cast(encode_uint16(message[start], message[start + 1])); +} + +void SystaSolarAquaSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_SENSOR(" ", "Temperature TSA", this->temperature_tsa_sensor_); + LOG_SENSOR(" ", "Temperature TSE", this->temperature_tse_sensor_); + LOG_SENSOR(" ", "Temperature TWU", this->temperature_twu_sensor_); + LOG_SENSOR(" ", "Temperature TW2", this->temperature_tw2_sensor_); + LOG_SENSOR(" ", "Pump Speed", this->pump_speed_sensor_); +} + +void SystaSolarAquaSensor::handle_message(std::span message) { + if (get_message_type(message) != MESSAGE_TYPE_AQUA_SENSOR_DATA) + return; + if (this->temperature_tsa_sensor_ != nullptr) + this->temperature_tsa_sensor_->publish_state(get_i16be(message, 4) * 0.1f); + if (this->temperature_tse_sensor_ != nullptr) + this->temperature_tse_sensor_->publish_state(get_i16be(message, 6) * 0.1f); + if (this->temperature_twu_sensor_ != nullptr) + this->temperature_twu_sensor_->publish_state(get_i16be(message, 8) * 0.1f); + if (this->temperature_tw2_sensor_ != nullptr) + this->temperature_tw2_sensor_->publish_state(get_i16be(message, 10) * 0.1f); + if (this->pump_speed_sensor_ != nullptr) + this->pump_speed_sensor_->publish_state(message[12]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h new file mode 100644 index 00000000000..3688176d006 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -0,0 +1,28 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } + void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } + void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } + void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } + void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + sensor::Sensor *temperature_tsa_sensor_{nullptr}; + sensor::Sensor *temperature_tse_sensor_{nullptr}; + sensor::Sensor *temperature_twu_sensor_{nullptr}; + sensor::Sensor *temperature_tw2_sensor_{nullptr}; + sensor::Sensor *pump_speed_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp new file mode 100644 index 00000000000..3a2f6717e9b --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -0,0 +1,70 @@ +#include "systa_bus.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::systa_bus { + +static const char *const TAG = "systa_bus"; + +void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } + +static bool checksum(std::span data) { + uint8_t csum = 0; + for (uint8_t i : data) + csum += i; + return csum == 0; +} + +void SystaBus::loop() { + uint8_t c; + while (this->available() && this->read_byte(&c)) + this->handle_byte_(c); +} + +void SystaBus::handle_byte_(uint8_t c) { + if (this->buffer_.empty()) { + if (c == START_BYTE) + this->buffer_.push_back(c); + return; + } + this->buffer_.push_back(c); + if (this->buffer_.size() == 2) { + // The length byte is only trusted for known message types; anything else restarts the search + uint16_t message_type = get_message_type(this->buffer_); + if (message_type != MESSAGE_TYPE_AQUA_SENSOR_DATA) { + ESP_LOGV(TAG, "Unknown message type 0x%04x", message_type); + this->buffer_.clear(); + // A stray start byte followed by a real frame: keep this byte as the new start + if (c == START_BYTE) + this->buffer_.push_back(c); + } + return; + } + if (this->buffer_.size() < this->buffer_[1] + FRAME_OVERHEAD) + return; + if (!checksum(this->buffer_)) { + ESP_LOGW(TAG, "Checksum failed"); + this->resync_(); + return; + } +#ifdef SYSTA_BUS_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->handle_message(this->buffer_); +#endif + this->buffer_.clear(); +} + +// A frame that lost a byte swallows the start of the next one, so the failed bytes are fed back through the +// parser from the second byte on. They are fewer than a full frame, so this cannot fail the checksum again. +void SystaBus::resync_() { + std::array failed; + const size_t count = this->buffer_.size(); + std::copy(this->buffer_.begin(), this->buffer_.end(), failed.begin()); + this->buffer_.clear(); + for (size_t i = 1; i < count; i++) + this->handle_byte_(failed[i]); +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h new file mode 100644 index 00000000000..99ca31590f5 --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::systa_bus { + +// A frame is a start byte, the payload length, the payload and an 8-bit checksum that makes all bytes sum to +// zero. The first two bytes together are the message type, so the type also fixes the frame length. +static constexpr uint8_t START_BYTE = 0xfc; +static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum + +// SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; + +// The bus only buffers known message types, so the buffer is sized by the longest of them +static constexpr size_t MAX_MESSAGE_SIZE = (MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff) + FRAME_OVERHEAD; + +inline uint16_t get_message_type(std::span message) { return encode_uint16(message[0], message[1]); } + +class SystaBusListener { + public: + // Called with every frame that passed the checksum; listeners pick their message type + virtual void handle_message(std::span message) = 0; +}; + +class SystaBus : public uart::UARTDevice, public Component { + public: + void dump_config() override; + void loop() override; + +#ifdef SYSTA_BUS_LISTENER_COUNT + void register_listener(SystaBusListener *listener) { this->listeners_.push_back(listener); } +#endif + + protected: + void handle_byte_(uint8_t c); + void resync_(); + +#ifdef SYSTA_BUS_LISTENER_COUNT + StaticVector listeners_; +#endif + StaticVector buffer_; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e1ae1d5fb60..75c7a76a408 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -175,6 +175,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO +#define SYSTA_BUS_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/systa_bus/__init__.py b/tests/components/systa_bus/__init__.py new file mode 100644 index 00000000000..95273b5ad58 --- /dev/null +++ b/tests/components/systa_bus/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Listener storage is sized by code generation; the gtests register one listener per bus. + cg.add_define("SYSTA_BUS_LISTENER_COUNT", 1) + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/systa_bus/common.h b/tests/components/systa_bus/common.h new file mode 100644 index 00000000000..9824059702e --- /dev/null +++ b/tests/components/systa_bus/common.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/components/systa_bus/systa_bus.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::systa_bus::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml new file mode 100644 index 00000000000..ea97e2d840b --- /dev/null +++ b/tests/components/systa_bus/common.yaml @@ -0,0 +1,15 @@ +systa_bus: + +sensor: + - platform: systa_bus + model: systasolar_aqua + temperature_tsa: + name: TSA + temperature_tse: + name: TSE + temperature_twu: + name: TWU + temperature_tw2: + name: TW2 + pump_speed: + name: Pump Speed diff --git a/tests/components/systa_bus/systa_bus_test.cpp b/tests/components/systa_bus/systa_bus_test.cpp new file mode 100644 index 00000000000..3e6de9d2800 --- /dev/null +++ b/tests/components/systa_bus/systa_bus_test.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "common.h" + +namespace esphome::systa_bus::testing { + +namespace { + +class TestListener final : public SystaBusListener { + public: + void handle_message(std::span message) override { + this->messages.emplace_back(message.begin(), message.end()); + } + std::vector> messages; +}; + +void put_i16be(std::vector &frame, size_t offset, int16_t value) { + frame[offset] = static_cast(static_cast(value) >> 8); + frame[offset + 1] = static_cast(value); +} + +// A 25-byte Aqua sensor frame with a valid checksum +std::vector aqua_frame(int16_t tsa, int16_t tse, int16_t twu, int16_t tw2, uint8_t pump) { + std::vector frame(MAX_MESSAGE_SIZE, 0); + frame[0] = START_BYTE; + frame[1] = MESSAGE_TYPE_AQUA_SENSOR_DATA & 0xff; + put_i16be(frame, 4, tsa); + put_i16be(frame, 6, tse); + put_i16be(frame, 8, twu); + put_i16be(frame, 10, tw2); + frame[12] = pump; + uint8_t sum = 0; + for (size_t i = 0; i + 1 < frame.size(); i++) + sum += frame[i]; + frame.back() = static_cast(0 - sum); + return frame; +} + +class SystaBusTest : public ::testing::Test { + protected: + void SetUp() override { + this->bus_.set_uart_parent(&this->uart_); + this->bus_.register_listener(&this->listener_); + } + void feed_(const std::vector &bytes) { + this->uart_.push_rx(bytes); + this->bus_.loop(); + } + + MockUARTComponent uart_; + SystaBus bus_; + TestListener listener_; +}; + +const std::vector FRAME_A = aqua_frame(512, 301, -15, 700, 60); +const std::vector FRAME_B = aqua_frame(513, 302, -14, 701, 61); + +} // namespace + +TEST_F(SystaBusTest, DecodesAValidFrame) { + this->feed_(FRAME_A); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, IgnoresGarbageBeforeAFrame) { + std::vector bytes = {0x00, 0x16, 0x42, 0xfc}; // includes a stray start byte + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, SkipsAnUnknownMessageType) { + std::vector bytes = {START_BYTE, 0x1a, 0x01, 0x02, 0x03}; + bytes.insert(bytes.end(), FRAME_A.begin(), FRAME_A.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, RejectsACorruptedByte) { + auto bad = FRAME_A; + bad[6] ^= 0x10; + std::vector bytes = bad; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +// A frame that lost a byte swallows the start byte of the next frame; the parser must still find that frame. +TEST_F(SystaBusTest, RecoversTheFrameAfterADroppedByte) { + auto truncated = FRAME_A; + truncated.erase(truncated.begin() + 9); + std::vector bytes = truncated; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_B); +} + +TEST_F(SystaBusTest, DecodesFramesSplitAcrossLoops) { + std::vector first(FRAME_A.begin(), FRAME_A.begin() + 10); + std::vector second(FRAME_A.begin() + 10, FRAME_A.end()); + this->feed_(first); + EXPECT_TRUE(this->listener_.messages.empty()); + this->feed_(second); + ASSERT_EQ(this->listener_.messages.size(), 1u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); +} + +TEST_F(SystaBusTest, DecodesBackToBackFrames) { + std::vector bytes = FRAME_A; + bytes.insert(bytes.end(), FRAME_B.begin(), FRAME_B.end()); + this->feed_(bytes); + ASSERT_EQ(this->listener_.messages.size(), 2u); + EXPECT_EQ(this->listener_.messages[0], FRAME_A); + EXPECT_EQ(this->listener_.messages[1], FRAME_B); +} + +} // namespace esphome::systa_bus::testing diff --git a/tests/components/systa_bus/test.esp32-idf.yaml b/tests/components/systa_bus/test.esp32-idf.yaml new file mode 100644 index 00000000000..0393d30348d --- /dev/null +++ b/tests/components/systa_bus/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + systa_bus: !include common.yaml From 1de5a58dbe8377220f9d7d3d68d9917b562e29c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 02:36:31 +0100 Subject: [PATCH 566/759] [display] Use register_apply_condition for display.is_displaying_page (#19705) --- esphome/components/display/__init__.py | 13 ++----------- esphome/components/display/display.h | 12 ------------ tests/components/ssd1306_i2c/common-actions.yaml | 16 ++++++++++++++++ .../ssd1306_i2c/test-actions.esp8266-ard.yaml | 7 +++++++ 4 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 tests/components/ssd1306_i2c/common-actions.yaml create mode 100644 tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index b664579be93..1cd75ddaf07 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,9 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayIsDisplayingPageCondition = display_ns.class_( - "DisplayIsDisplayingPageCondition", automation.Condition -) DisplayOnPageChangeTrigger = display_ns.class_( "DisplayOnPageChangeTrigger", automation.Trigger ) @@ -301,9 +298,8 @@ automation.register_apply_action( ) -@automation.register_condition( +automation.register_apply_condition( "display.is_displaying_page", - DisplayIsDisplayingPageCondition, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Display), @@ -311,13 +307,8 @@ automation.register_apply_action( }, key=CONF_PAGE_ID, ), + automation.ApplyCall("get_active_page() == {}", ((CONF_PAGE_ID, DisplayPagePtr),)), ) -async def display_is_displaying_page_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - page = await cg.get_variable(config[CONF_PAGE_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - cg.add(var.set_page(page)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index af5a7bc1324..bebeba8dcb2 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -848,18 +848,6 @@ template class DisplayPageShowAction final : public Action class DisplayIsDisplayingPageCondition final : public Condition { - public: - DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} - - void set_page(DisplayPage *page) { this->page_ = page; } - bool check(const Ts &...x) override { return this->parent_->get_active_page() == this->page_; } - - protected: - Display *parent_; - DisplayPage *page_; -}; - class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } diff --git a/tests/components/ssd1306_i2c/common-actions.yaml b/tests/components/ssd1306_i2c/common-actions.yaml new file mode 100644 index 00000000000..737fe7f26b9 --- /dev/null +++ b/tests/components/ssd1306_i2c/common-actions.yaml @@ -0,0 +1,16 @@ +button: + - platform: template + name: Page check + on_press: + - if: + condition: + display.is_displaying_page: ssd1306_i2c_page1 + then: + - logger.log: On page 1 + - if: + condition: + display.is_displaying_page: + id: ssd1306_i2c_ssd1306_i2c_display + page_id: ssd1306_i2c_page2 + then: + - logger.log: On page 2 diff --git a/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..e18129c9199 --- /dev/null +++ b/tests/components/ssd1306_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + reset_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ssd1306_i2c: !include common.yaml + actions: !include common-actions.yaml From 974405820179d4686c79cb574a8d8cff77a1dafa Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:14:12 +0000 Subject: [PATCH 567/759] Bump bundled esphome-device-builder to 1.16.0 (#19710) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d94a7ce41a7..c68cd0f4ba4 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.15.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.16.0 RUN \ platformio settings set enable_telemetry No \ From 8132fbef525f3026fac1a1a578428f25b7e9e491 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 11:01:53 +0100 Subject: [PATCH 568/759] [socket] Add outgoing connections to the raw lwIP TCP implementation (#18982) --- .../components/async_tcp/async_tcp_socket.cpp | 54 ++--- .../components/esphome/ota/ota_esphome.cpp | 5 +- .../components/socket/bsd_sockets_impl.cpp | 6 +- esphome/components/socket/headers.h | 7 + .../components/socket/lwip_raw_tcp_impl.cpp | 206 ++++++++++++------ esphome/components/socket/lwip_raw_tcp_impl.h | 19 ++ .../components/socket/lwip_sockets_impl.cpp | 6 +- esphome/components/socket/socket.cpp | 49 ++++- esphome/components/socket/socket.h | 8 + esphome/components/udp/udp_component.cpp | 13 +- .../components/wake_on_lan/wake_on_lan.cpp | 4 + .../socket/test-ipv6.rp2040-ard.yaml | 4 + 12 files changed, 274 insertions(+), 107 deletions(-) create mode 100644 tests/components/socket/test-ipv6.rp2040-ard.yaml diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index 10cbc981c71..853ff206c25 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -6,7 +6,6 @@ #include "esphome/components/network/util.h" #include "esphome/core/log.h" #include -#include namespace esphome::async_tcp { @@ -42,7 +41,15 @@ bool AsyncClient::connect(const char *host, uint16_t port) { return false; } - socket_->setblocking(false); + if (socket_->setblocking(false) != 0) { + // Capture before the log and close() clobber errno + const int saved_errno = errno; + ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno); + close(); + if (error_cb_) + error_cb_(error_arg_, this, saved_errno); + return false; + } int err = socket_->connect((struct sockaddr *) &addr, addrlen); if (err == 0) { @@ -97,45 +104,22 @@ void AsyncClient::loop() { return; if (connecting_) { - // For connecting, we need to check writability, not readability - // The Application's select() only monitors read FDs, so we do our own check here - // For ESP platforms lwip_select() might be faster, but this code isn't used - // on those platforms anyway. If it was, we'd fix the Application select() - // to report writability instead of doing it this way. - int fd = socket_->get_fd(); - if (fd < 0) { - ESP_LOGW(TAG, "Invalid socket fd"); - close(); - return; - } - - fd_set writefds; - FD_ZERO(&writefds); - FD_SET(fd, &writefds); - - struct timeval tv = {0, 0}; - int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv); - - if (ret > 0 && FD_ISSET(fd, &writefds)) { - int error = 0; - socklen_t len = sizeof(error); - if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) { + int err = 0; + switch (socket::poll_connect(*socket_, err)) { + case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED: connecting_ = false; connected_ = true; if (connect_cb_) connect_cb_(connect_arg_, this); - } else { - ESP_LOGW(TAG, "Connection failed: %d", error); + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR: + ESP_LOGW(TAG, "Connection failed: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, error); - } - } else if (ret < 0) { - const int err = errno; - ESP_LOGE(TAG, "Select error: %d", err); - close(); - if (error_cb_) - error_cb_(error_arg_, this, err); + error_cb_(error_arg_, this, err); + break; } } else if (connected_) { // For connected sockets, use the Application's select() results diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6248474624b..22580343e88 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -459,7 +459,10 @@ void ESPHomeOTAComponent::handle_data_() { tv.tv_usec = 0; this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); - this->client_->setblocking(true); + if (this->client_->setblocking(true) != 0) { + this->log_socket_error_(LOG_STR("blocking")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } // Acknowledge auth OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index 0d4284f1456..cec3b3498a7 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -59,13 +59,15 @@ int BSDSocketImpl::close() { int BSDSocketImpl::setblocking(bool blocking) { int fl = ::fcntl(this->fd_, F_GETFL, 0); + if (fl < 0) { + return fl; + } if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - ::fcntl(this->fd_, F_SETFL, fl); - return 0; + return ::fcntl(this->fd_, F_SETFL, fl); } size_t BSDSocketImpl::getpeername_to(std::span buf) { diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 528d201799b..f4597f2522f 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -205,6 +205,13 @@ static constexpr size_t SOCKADDR_STR_LEN = 46; // INET6_ADDRSTRLEN static constexpr size_t SOCKADDR_STR_LEN = 16; // INET_ADDRSTRLEN #endif +/// Outcome of polling a non-blocking connect(); see socket::poll_connect(). +enum class ConnectPollResult : uint8_t { + CONNECT_POLL_RESULT_PENDING, + CONNECT_POLL_RESULT_CONNECTED, + CONNECT_POLL_RESULT_ERROR, +}; + } // namespace esphome::socket #endif diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 8d00dbede2a..b411445b98a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -48,8 +48,33 @@ 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; +// Let SYS run so queued WiFi traffic reaches lwip; CONT and SYS are cooperative +static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); } +#else +static inline void yield_to_sys() {} #endif +// errno for a failed tcp_* call +static int lwip_err_to_errno(err_t err) { + switch (err) { + case ERR_MEM: + return ENOMEM; + case ERR_BUF: + return EAGAIN; // transient, e.g. no free local port + case ERR_RTE: + return EHOSTUNREACH; // no route, e.g. no address yet + case ERR_VAL: + case ERR_ARG: + return EINVAL; + case ERR_USE: + return EADDRINUSE; + case ERR_ISCONN: + return EISCONN; + default: + return EIO; + } +} + // 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__) @@ -62,8 +87,8 @@ static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000; // Must be called before destroying the object that tcp_arg points to — // tcp_abort() triggers the err callback synchronously, which would // otherwise call back into a partially-destroyed object. -// tcp_sent/tcp_poll are not cleared because this implementation -// never registers them. +// tcp_sent/tcp_poll are never registered and the connect callback cannot +// fire after abort or close, so neither is cleared. static void pcb_detach_abort(struct tcp_pcb *pcb) { tcp_arg(pcb, nullptr); tcp_recv(pcb, nullptr); @@ -76,8 +101,7 @@ static void pcb_detach_abort(struct tcp_pcb *pcb) { // After tcp_close(), the PCB remains alive during the TCP close handshake // (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP // would call recv/err on a destroyed socket object, corrupting the heap. -// tcp_sent/tcp_poll are not cleared because this implementation -// never registers them. +// Callbacks are left as in pcb_detach_abort(). // Returns ERR_OK on success; on failure the PCB is aborted instead. static err_t pcb_detach_close(struct tcp_pcb *pcb) { tcp_arg(pcb, nullptr); @@ -101,67 +125,51 @@ LWIPRawCommon::~LWIPRawCommon() { } } +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const { + if (name == nullptr) { + errno = EINVAL; + return false; + } +#if LWIP_IPV6 + if (this->family_ == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) { + errno = EINVAL; + return false; + } + auto *addr6 = reinterpret_cast(name); + *port = ntohs(addr6->sin6_port); + inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr); + // ANY lets bind() accept both families; connect() picks the concrete type + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); + return true; + } +#endif + if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) { + errno = EINVAL; + return false; + } + auto *addr4 = reinterpret_cast(name); + *port = ntohs(addr4->sin_port); + ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr); + return true; +} + int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; } - if (name == nullptr) { - errno = EINVAL; - return -1; - } ip_addr_t ip; - in_port_t port; -#if LWIP_IPV6 - if (this->family_ == AF_INET) { - if (addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.type = IPADDR_TYPE_V4; - ip.u_addr.ip4.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip4addr_ntoa(&ip.u_addr.ip4), port); - } else if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return -1; - } - auto *addr6 = reinterpret_cast(name); - port = ntohs(addr6->sin6_port); - ip.type = IPADDR_TYPE_ANY; - memcpy(&ip.u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip6addr_ntoa(&ip.u_addr.ip6), port); - } else { - errno = EINVAL; + uint16_t port; + if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) { return -1; } -#else - if (this->family_ != AF_INET) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%u port=%u)", this->pcb_, ip.addr, port); -#endif + LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); err_t err = tcp_bind(this->pcb_, &ip, port); - if (err == ERR_USE) { - LWIP_LOG(" -> err ERR_USE"); - errno = EADDRINUSE; - return -1; - } - if (err == ERR_VAL) { - LWIP_LOG(" -> err ERR_VAL"); - errno = EINVAL; - return -1; - } if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -178,7 +186,7 @@ int LWIPRawCommon::close() { this->pcb_ = nullptr; if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = err == ERR_MEM ? ENOMEM : EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -205,7 +213,7 @@ int LWIPRawCommon::shutdown(int how) { err_t err = tcp_shutdown(this->pcb_, shut_rx, shut_tx); if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - errno = err == ERR_MEM ? ENOMEM : EIO; + errno = lwip_err_to_errno(err); return -1; } return 0; @@ -425,7 +433,82 @@ void LWIPRawImpl::s_err_fn(void *arg, err_t err) { // ERR_ABRT: aborted through tcp_abort or TCP timer auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); + if (arg_this->connect_err_ == EINPROGRESS) { + // Refused (RST) or SYN retries exhausted; written before pcb_ so + // poll_connect() never sees a dead pcb without its reason + arg_this->connect_err_ = err == ERR_RST ? ECONNREFUSED : ETIMEDOUT; + } arg_this->pcb_ = nullptr; + esphome::wake_loop_any_context(); +} + +err_t LWIPRawImpl::s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err) { + // LWIP CALLBACK, same constraints as s_err_fn; err is always ERR_OK + auto *arg_this = reinterpret_cast(arg); + arg_this->connect_err_ = EISCONN; + esphome::wake_loop_any_context(); + return ERR_OK; +} + +int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->connect_err_ == EINPROGRESS || this->connect_err_ == EISCONN) { + errno = this->connect_err_ == EINPROGRESS ? EALREADY : EISCONN; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) { + return -1; + } +#if LWIP_IPV6 + // tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped + if (IP_IS_ANY_TYPE_VAL(ip)) { + if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) { + unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip)); + IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4); + } else { + IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6); + } + } +#endif + LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); + err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn); + if (err != ERR_OK) { + LWIP_LOG(" -> err %d", err); + errno = lwip_err_to_errno(err); + return -1; + } + this->connect_err_ = EINPROGRESS; + errno = EINPROGRESS; + return -1; +} + +ConnectPollResult LWIPRawImpl::poll_connect(int &err_out) const { + // pcb_ first; see the ordering note on the declaration + if (this->pcb_ == nullptr) { + // Only a recorded connect failure carries its own reason + const bool failed = this->connect_err_ == ECONNREFUSED || this->connect_err_ == ETIMEDOUT; + err_out = failed ? this->connect_err_ : ECONNRESET; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + switch (this->connect_err_) { + case EINPROGRESS: + yield_to_sys(); // so the SYN-ACK is processed between polls + return ConnectPollResult::CONNECT_POLL_RESULT_PENDING; + case EISCONN: + return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED; + case 0: + err_out = EINVAL; // no connect was started + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + default: + err_out = this->connect_err_; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } } err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) { @@ -540,14 +623,11 @@ 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). + // Let queued WiFi RX reach lwip first; otherwise inbound segments can + // sit unprocessed for seconds while the main loop polls if (this->waiting_for_data_()) { - optimistic_yield(ESP8266_YIELD_INTERVAL_US); + yield_to_sys(); } -#endif // See waiting_for_data_() for safety of unlocked reads. if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { this->wait_for_data_(); @@ -636,12 +716,10 @@ int LWIPRawImpl::internal_output_() { 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 + yield_to_sys(); return 0; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 917b5b2f7a2..ed8057e289a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,6 +50,8 @@ class LWIPRawCommon { protected: int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; @@ -58,7 +60,14 @@ class LWIPRawCommon { bool nodelay_ = false; sa_family_t family_ = 0; uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) + // 0 before connect(), EINPROGRESS while pending, EISCONN once established, + // else the failure errno the callbacks recorded; fills the padding byte + uint8_t connect_err_ = 0; + static_assert(EINPROGRESS < 256 && EISCONN < 256 && ECONNREFUSED < 256 && ECONNRESET < 256 && ETIMEDOUT < 256, + "connect_err_ stores errno values in a byte"); }; +// The connect state must stay in the padding so no socket pays RAM for it +static_assert(sizeof(LWIPRawCommon) == sizeof(struct tcp_pcb *) + 4, "LWIPRawCommon grew past one word of flags"); /// Connected socket implementation for LWIP raw TCP. /// No virtual methods — callers always use the concrete type. @@ -83,6 +92,12 @@ class LWIPRawImpl : public LWIPRawCommon { errno = EOPNOTSUPP; return -1; } + /// Non-blocking: returns -1/EINPROGRESS once the SYN is queued, see poll_connect(). + /// addr must match the socket family; an IPv4 peer on AF_INET6 arrives v4-mapped. + int connect(const struct sockaddr *addr, socklen_t addrlen); + // Unlocked like ready(): the callbacks write the error byte before pcb_, + // so a torn read only costs one extra poll + ConnectPollResult poll_connect(int &err_out) const; ssize_t read(void *buf, size_t len); ssize_t readv(const struct iovec *iov, int iovcnt); ssize_t recvfrom(void *, size_t, sockaddr *, socklen_t *) { @@ -120,6 +135,7 @@ class LWIPRawImpl : public LWIPRawCommon { static void s_err_fn(void *arg, err_t err); static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); + static err_t s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err); protected: // True when the socket could receive data but none has arrived yet. @@ -137,6 +153,9 @@ class LWIPRawImpl : public LWIPRawCommon { size_t rx_buf_offset_ = 0; bool rx_closed_ = false; }; +// rx_buf_, rx_buf_offset_, then rx_closed_ padded to a word +static_assert(sizeof(LWIPRawImpl) == sizeof(LWIPRawCommon) + sizeof(pbuf *) + sizeof(size_t) + 4, + "LWIPRawImpl layout changed"); /// Listening socket implementation for LWIP raw TCP. /// Separate from LWIPRawImpl — no virtual dispatch needed. diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index a6bd639c10f..f87c5377e4e 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -49,13 +49,15 @@ int LwIPSocketImpl::close() { int LwIPSocketImpl::setblocking(bool blocking) { int fl = lwip_fcntl(this->fd_, F_GETFL, 0); + if (fl < 0) { + return fl; + } if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - lwip_fcntl(this->fd_, F_SETFL, fl); - return 0; + return lwip_fcntl(this->fd_, F_SETFL, fl); } size_t LwIPSocketImpl::getpeername_to(std::span buf) { diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 567937a4db0..f2d428af772 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -2,6 +2,9 @@ #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) #include #include +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#include +#endif #include #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -165,7 +168,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ #else // Use LWIP-specific functions ip6_addr_t ip6; - inet6_aton(ip_address, &ip6); + if (inet6_aton(ip_address, &ip6) == 0) { + errno = EINVAL; + return 0; + } memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); #endif return sizeof(sockaddr_in6); @@ -195,6 +201,47 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return sizeof(sockaddr_in); } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +ConnectPollResult poll_connect(Socket &sock, int &err_out) { + int fd = sock.get_fd(); + if (fd < 0 || fd >= FD_SETSIZE) { + // FD_SET on either is undefined behavior + err_out = EBADF; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + // Connect completion is a write event; the main loop only selects on reads + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(fd, &writefds); + struct timeval tv = {0, 0}; +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS + // LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly + int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv); +#else + // Global-scope select: the entity namespace esphome::select shadows it here + int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv); +#endif + if (ret < 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + if (ret == 0) { + return ConnectPollResult::CONNECT_POLL_RESULT_PENDING; + } + int error = 0; + socklen_t len = sizeof(error); + if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + if (error != 0) { + err_out = error; + return ConnectPollResult::CONNECT_POLL_RESULT_ERROR; + } + return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED; +} +#endif + socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) { #if USE_NETWORK_IPV6 if (addrlen < sizeof(sockaddr_in6)) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index eb8870786d0..8ce392ff1dd 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -145,6 +145,14 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or +/// errno) on fd implementations and the failure the callbacks recorded on raw lwip. +#ifdef USE_SOCKET_IMPL_LWIP_TCP +inline ConnectPollResult poll_connect(Socket &sock, int &err_out) { return sock.poll_connect(err_out); } +#else +ConnectPollResult poll_connect(Socket &sock, int &err_out); +#endif + /// Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf); diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 858516c7462..f662cba4cfb 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -13,7 +13,12 @@ void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const auto &address : this->addresses_) { struct sockaddr saddr {}; - socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_); + if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { + ESP_LOGW(TAG, "Invalid address %s", address); + // A dropped address silently receives nothing; surface the misconfiguration + this->status_set_warning(LOG_STR("invalid address")); + continue; + } this->sockaddrs_.push_back(saddr); } // set up broadcast socket @@ -94,7 +99,11 @@ void UDPComponent::setup() { // 8266 and RP2040 `Duino for (const auto &address : this->addresses_) { auto ipaddr = IPAddress(); - ipaddr.fromString(address); + if (!ipaddr.fromString(address)) { + ESP_LOGW(TAG, "Invalid address %s", address); + this->status_set_warning(LOG_STR("invalid address")); + continue; + } this->ipaddrs_.push_back(ipaddr); } if (this->should_listen_) diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index e46b96c86a8..fd94f56b755 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -34,6 +34,10 @@ void WakeOnLanButton::press_action() { struct sockaddr_storage saddr {}; auto addr_len = socket::set_sockaddr(reinterpret_cast(&saddr), sizeof(saddr), "255.255.255.255", this->port_); + if (addr_len == 0) { + ESP_LOGW(TAG, "Invalid broadcast address"); + return; + } uint8_t buffer[6 + sizeof this->macaddr_ * 16]; memcpy(buffer, PREFIX, sizeof(PREFIX)); for (size_t i = 0; i != 16; i++) { diff --git a/tests/components/socket/test-ipv6.rp2040-ard.yaml b/tests/components/socket/test-ipv6.rp2040-ard.yaml new file mode 100644 index 00000000000..da1324b17ef --- /dev/null +++ b/tests/components/socket/test-ipv6.rp2040-ard.yaml @@ -0,0 +1,4 @@ +substitutions: + network_enable_ipv6: "true" + +<<: !include common.yaml From 7d5117e570d5401cc046dba0201cf8122a81b8f8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:18:15 +1000 Subject: [PATCH 569/759] [snapshot] Add feature to record animated gifs from screen (#19421) Co-authored-by: Claude Sonnet 5 --- esphome/components/snapshot/__init__.py | 34 ++- esphome/components/snapshot/gif.cpp | 283 ++++++++++++++++++ esphome/components/snapshot/gif.h | 51 ++++ esphome/components/snapshot/snapshot.cpp | 254 ++++++++++++---- esphome/components/snapshot/snapshot.h | 35 ++- tests/components/snapshot/common.yaml | 5 + tests/components/snapshot/snapshot_test.cpp | 125 ++++++++ .../fixtures/snapshot_display.yaml | 61 ++++ tests/integration/test_snapshot_display.py | 117 +++++++- 9 files changed, 888 insertions(+), 77 deletions(-) create mode 100644 esphome/components/snapshot/gif.cpp create mode 100644 esphome/components/snapshot/gif.h create mode 100644 tests/components/snapshot/snapshot_test.cpp diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py index 068af830aad..1404263ce63 100644 --- a/esphome/components/snapshot/__init__.py +++ b/esphome/components/snapshot/__init__.py @@ -21,20 +21,42 @@ CODEOWNERS = ["@clydebarrow"] DOMAIN = "snapshot" CONF_FILENAME = "filename" +CONF_FRAMES = "frames" +CONF_FRAME_RATE = "frame_rate" snapshot_ns = cg.esphome_ns.namespace("snapshot") Snapshot = snapshot_ns.class_("Snapshot") +def _default_animation(config: ConfigType) -> ConfigType: + """Without frames the action takes a single picture, which the C++ side reads as zero frames.""" + return {CONF_FRAMES: 0, CONF_FRAME_RATE: 0.0, **config} + + automation.register_apply_action( "snapshot.take", - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Snapshot), - cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), - } + cv.All( + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Snapshot), + cv.Optional(CONF_FILENAME, default=""): cv.templatable(cv.string), + # Asking for frames makes a GIF instead of a single BMP picture. + cv.Inclusive(CONF_FRAMES, "animation"): cv.positive_not_null_int, + cv.Inclusive(CONF_FRAME_RATE, "animation"): cv.All( + cv.framerate, cv.Range(min=0.1, max=50) + ), + } + ), + _default_animation, + ), + automation.ApplyCall( + "take_snapshot_or_log({}, {}, {})", + ( + (CONF_FILENAME, cg.std_string), + (CONF_FRAMES, cg.uint32), + (CONF_FRAME_RATE, cg.float_), + ), ), - automation.ApplyField(CONF_FILENAME, "take_snapshot_or_log", cg.std_string), ) diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp new file mode 100644 index 00000000000..5650c1c404b --- /dev/null +++ b/esphome/components/snapshot/gif.cpp @@ -0,0 +1,283 @@ +#ifdef USE_HOST +#include "gif.h" + +#include +#include +#include + +namespace esphome::snapshot { + +namespace { + +constexpr size_t MAX_PALETTE_SIZE = 256; +// GIF needs at least two bits for each colour, even when the picture has fewer than four colours. +constexpr unsigned MIN_PALETTE_BITS = 2; +constexpr unsigned MAX_CODE_BITS = 12; +constexpr uint32_t MAX_CODES = 1u << MAX_CODE_BITS; +// Twice the number of codes there can be, so a lookup rarely has to look at more than one entry. +constexpr size_t DICTIONARY_SIZE = 2 * MAX_CODES; +constexpr uint32_t EMPTY_ENTRY = 0xFFFFFFFF; +constexpr size_t MAX_SUB_BLOCK_SIZE = 255; +// Colours are grouped by their top 5, 6 and 5 bits, which is all an RGB565 display can show. +constexpr size_t COLOR_KEY_COUNT = 1u << 16; + +constexpr uint16_t color_key(uint8_t red, uint8_t green, uint8_t blue) { + return static_cast(((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3)); +} + +/// One of the three parts of a colour key: 0 for red, 1 for green, 2 for blue. The value is spread +/// over 0 to 255 whichever part it is, so the parts can be compared with each other. +constexpr unsigned key_channel(uint16_t key, unsigned channel) { + switch (channel) { + case 0: + return (key >> 11) << 3; + case 1: + return ((key >> 5) & 0x3F) << 2; + default: + return (key & 0x1F) << 3; + } +} + +/// Store a value in two bytes, least significant first. +void put_le16(std::vector &out, unsigned value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast(value >> 8)); +} + +/// Packs codes of varying length into bytes, least significant bit first. +struct BitWriter { + std::vector &out; + uint32_t buffer{0}; + unsigned count{0}; + + void put(uint32_t code, unsigned size) { + this->buffer |= code << this->count; + this->count += size; + while (this->count >= 8) { + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer >>= 8; + this->count -= 8; + } + } + + void flush() { + if (this->count > 0) + this->out.push_back(static_cast(this->buffer & 0xFF)); + this->buffer = 0; + this->count = 0; + } +}; + +/// Compress palette indices the way GIF asks for, appending the bytes to `out`. +void lzw_compress(const uint8_t *data, size_t length, unsigned min_code_size, std::vector &out) { + struct Entry { + uint32_t key; // the code before, shifted up a byte, with the next index in the low byte + uint16_t code; + }; + const uint32_t clear_code = 1u << min_code_size; + const uint32_t end_code = clear_code + 1; + std::vector dictionary(DICTIONARY_SIZE); + BitWriter bits{out}; + uint32_t next_code = 0; + unsigned code_size = 0; + + auto reset = [&]() { + std::fill(dictionary.begin(), dictionary.end(), Entry{EMPTY_ENTRY, 0}); + next_code = end_code + 1; + code_size = min_code_size + 1; + }; + + reset(); + bits.put(clear_code, code_size); + uint32_t prefix = data[0]; + for (size_t i = 1; i != length; i++) { + const uint32_t key = prefix << 8 | data[i]; + size_t slot = (key * 2654435761u >> 16) & (DICTIONARY_SIZE - 1); + while (dictionary[slot].key != EMPTY_ENTRY && dictionary[slot].key != key) + slot = (slot + 1) & (DICTIONARY_SIZE - 1); + if (dictionary[slot].key == key) { + prefix = dictionary[slot].code; + continue; + } + bits.put(prefix, code_size); + if (next_code < MAX_CODES) { + dictionary[slot] = Entry{key, static_cast(next_code++)}; + // The reader is one entry behind, so it moves to longer codes a step after this does. + if (next_code > (1u << code_size)) + code_size++; + } else { + bits.put(clear_code, code_size); + reset(); + } + prefix = data[i]; + } + bits.put(prefix, code_size); + // The reader adds an entry for that last code, which may take it to longer codes. + if (next_code == (1u << code_size) && code_size < MAX_CODE_BITS) + code_size++; + bits.put(end_code, code_size); + bits.flush(); +} + +} // namespace + +GifWriter::GifWriter(FILE *file, int width, int height) + : file_(file), + width_(width), + height_(height), + bins_(COLOR_KEY_COUNT), + palette_index_(COLOR_KEY_COUNT), + indices_(static_cast(width) * height) {} + +bool GifWriter::write_header() { + this->output_.clear(); + static constexpr char SIGNATURE[] = "GIF89a"; + this->output_.insert(this->output_.end(), SIGNATURE, SIGNATURE + 6); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(0x70); // no shared colour table; 8 bits per colour + this->output_.push_back(0); // background colour, unused + this->output_.push_back(0); // pixels are square + // Ask players to repeat the animation forever. + static constexpr char LOOP_EXTENSION[] = "\x21\xFF\x0BNETSCAPE2.0\x03\x01"; + this->output_.insert(this->output_.end(), LOOP_EXTENSION, LOOP_EXTENSION + sizeof(LOOP_EXTENSION) - 1); + put_le16(this->output_, 0); + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +size_t GifWriter::build_palette_(uint8_t *palette) { + struct Box { + size_t begin; // range of used_colors_, which the box owns + size_t end; + uint64_t pixels; + }; + + this->used_colors_.clear(); + uint64_t total = 0; + for (size_t key = 0; key != COLOR_KEY_COUNT; key++) { + if (this->bins_[key].count != 0) { + this->used_colors_.push_back(static_cast(key)); + total += this->bins_[key].count; + } + } + + // Median cut: keep cutting the box with the most pixels in two, across the colour part that + // varies most, until there is a box for each palette entry or every box is a single colour. + std::vector boxes; + boxes.push_back({0, this->used_colors_.size(), total}); + while (boxes.size() < MAX_PALETTE_SIZE) { + size_t chosen = boxes.size(); + for (size_t i = 0; i != boxes.size(); i++) { + if (boxes[i].end - boxes[i].begin >= 2 && (chosen == boxes.size() || boxes[i].pixels > boxes[chosen].pixels)) + chosen = i; + } + if (chosen == boxes.size()) + break; + const Box box = boxes[chosen]; + + unsigned low[3] = {255, 255, 255}; + unsigned high[3] = {0, 0, 0}; + for (size_t i = box.begin; i != box.end; i++) { + for (unsigned channel = 0; channel != 3; channel++) { + const unsigned value = key_channel(this->used_colors_[i], channel); + low[channel] = std::min(low[channel], value); + high[channel] = std::max(high[channel], value); + } + } + unsigned widest = 0; + for (unsigned channel = 1; channel != 3; channel++) { + if (high[channel] - low[channel] > high[widest] - low[widest]) + widest = channel; + } + std::sort(this->used_colors_.begin() + box.begin, this->used_colors_.begin() + box.end, + [widest](uint16_t a, uint16_t b) { + const unsigned value_a = key_channel(a, widest); + const unsigned value_b = key_channel(b, widest); + return value_a != value_b ? value_a < value_b : a < b; + }); + + // Cut where the pixels are half on one side and half on the other, keeping a colour on each. + size_t cut = box.begin; + uint64_t below = 0; + while (cut < box.end - 1 && below * 2 < box.pixels) + below += this->bins_[this->used_colors_[cut++]].count; + boxes[chosen] = {box.begin, cut, below}; + boxes.push_back({cut, box.end, box.pixels - below}); + } + + for (size_t i = 0; i != boxes.size(); i++) { + uint64_t sum[3] = {0, 0, 0}; + for (size_t j = boxes[i].begin; j != boxes[i].end; j++) { + const ColorBin &bin = this->bins_[this->used_colors_[j]]; + for (unsigned channel = 0; channel != 3; channel++) + sum[channel] += bin.sum[channel]; + this->palette_index_[this->used_colors_[j]] = static_cast(i); + } + // The average colour of the box, rounded to the nearest. + for (unsigned channel = 0; channel != 3; channel++) + palette[i * 3 + channel] = static_cast((sum[channel] + boxes[i].pixels / 2) / boxes[i].pixels); + } + return boxes.size(); +} + +bool GifWriter::write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds) { + std::fill(this->bins_.begin(), this->bins_.end(), ColorBin{}); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) { + ColorBin &bin = this->bins_[color_key(in[2], in[1], in[0])]; + bin.count++; + bin.sum[0] += in[2]; + bin.sum[1] += in[1]; + bin.sum[2] += in[0]; + } + } + + uint8_t palette[MAX_PALETTE_SIZE * 3] = {}; + const size_t colors = this->build_palette_(palette); + unsigned palette_bits = MIN_PALETTE_BITS; + while ((size_t{1} << palette_bits) < colors) + palette_bits++; + + uint8_t *out_index = this->indices_.data(); + for (int y = 0; y != this->height_; y++) { + const uint8_t *in = bgr + y * row_stride; + for (int x = 0; x != this->width_; x++, in += 3) + *out_index++ = this->palette_index_[color_key(in[2], in[1], in[0])]; + } + + this->output_.clear(); + // Show the frame for the time asked, then leave it in place under the next one. + static constexpr uint8_t GRAPHIC_CONTROL[] = {0x21, 0xF9, 0x04, 0x04}; + this->output_.insert(this->output_.end(), std::begin(GRAPHIC_CONTROL), std::end(GRAPHIC_CONTROL)); + put_le16(this->output_, delay_centiseconds); + this->output_.push_back(0); // no transparent colour + this->output_.push_back(0); + this->output_.push_back(0x2C); // the frame fills the whole picture + put_le16(this->output_, 0); + put_le16(this->output_, 0); + put_le16(this->output_, this->width_); + put_le16(this->output_, this->height_); + this->output_.push_back(static_cast(0x80 | (palette_bits - 1))); // has its own colour table + this->output_.insert(this->output_.end(), palette, palette + (size_t{3} << palette_bits)); + this->output_.push_back(static_cast(palette_bits)); + + std::vector compressed; + lzw_compress(this->indices_.data(), this->indices_.size(), palette_bits, compressed); + for (size_t pos = 0; pos < compressed.size(); pos += MAX_SUB_BLOCK_SIZE) { + const size_t length = std::min(MAX_SUB_BLOCK_SIZE, compressed.size() - pos); + this->output_.push_back(static_cast(length)); + this->output_.insert(this->output_.end(), compressed.begin() + pos, compressed.begin() + pos + length); + } + this->output_.push_back(0); + return fwrite(this->output_.data(), 1, this->output_.size(), this->file_) == this->output_.size(); +} + +bool GifWriter::write_trailer() { + const uint8_t trailer = 0x3B; + return fwrite(&trailer, 1, 1, this->file_) == 1; +} + +} // namespace esphome::snapshot +#endif diff --git a/esphome/components/snapshot/gif.h b/esphome/components/snapshot/gif.h new file mode 100644 index 00000000000..382f6499762 --- /dev/null +++ b/esphome/components/snapshot/gif.h @@ -0,0 +1,51 @@ +#pragma once + +#ifdef USE_HOST +#include +#include +#include +#include + +namespace esphome::snapshot { + +/// Writes an animated GIF a frame at a time, so a long recording is never held in memory. +/// +/// A GIF frame can use at most 256 colours. Each frame gets its own colour table, chosen from what +/// is in that frame, so a frame with 256 colours or fewer is stored exactly. +class GifWriter { + public: + GifWriter(FILE *file, int width, int height); + + /// Write the start of the file. This must come first. The animation repeats forever. + bool write_header(); + /// Add a frame from three bytes per pixel in blue, green, red order, topmost row first, with + /// `row_stride` bytes from the start of one row to the start of the next. + /// The frame is shown for `delay_centiseconds` hundredths of a second. + bool write_frame(const uint8_t *bgr, size_t row_stride, unsigned delay_centiseconds); + /// Write the end of the file. This must come last. + bool write_trailer(); + + protected: + /// How many pixels of one colour there are in the frame, and the sum of their red, green and blue. + struct ColorBin { + uint64_t sum[3]; + uint32_t count; + }; + + /// Choose up to 256 colours for the frame in `bins_`, storing them as red, green, blue triples in + /// `palette` and which one each colour is nearest in `palette_index_`. Returns how many were chosen. + size_t build_palette_(uint8_t *palette); + + FILE *file_; + int width_; + int height_; + std::vector bins_; + std::vector used_colors_; + std::vector palette_index_; + std::vector indices_; + std::vector output_; +}; + +} // namespace esphome::snapshot + +#endif diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 1bf63f80b00..096ec48eb3b 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -1,5 +1,8 @@ #ifdef USE_HOST #include "snapshot.h" +#include "gif.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -7,11 +10,13 @@ #include #include #include +#include #include #include #include #include #include +#include namespace esphome::snapshot { @@ -28,16 +33,19 @@ constexpr size_t BMP_HEADER_SIZE = 54; constexpr size_t BMP_INFO_HEADER_SIZE = 40; constexpr int BMP_BITS_PER_PIXEL = 24; -/// True if the name already ends in ".bmp". The comparison ignores case, so "shot.BMP" is left -/// alone rather than turned into "shot.BMP.bmp". -bool has_bmp_suffix(const std::string &name) { - return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, ".bmp") == 0; +// The largest width or height a GIF file can hold. +constexpr int GIF_MAX_SIZE = 0xFFFF; + +/// True if the name already ends in the extension, which is four characters including the dot. The +/// comparison ignores case, so "shot.BMP" is left alone rather than turned into "shot.BMP.bmp". +bool has_extension(const std::string &name, const char *extension) { + return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, extension) == 0; } /// Reduce a user supplied name to a single safe path component. Everything outside the allowed set /// is replaced, so "..", "/" and absolute paths cannot escape the snapshot directory. /// Returns an empty string if nothing usable is left. -std::string sanitise_filename(const char *const name, bool *name_changed) { +std::string sanitise_filename(const char *const name, const char *extension, bool *name_changed) { std::string result; bool all_dots = true; bool changed = false; @@ -59,8 +67,8 @@ std::string sanitise_filename(const char *const name, bool *name_changed) { *name_changed = true; return ""; } - if (!has_bmp_suffix(result)) - result += ".bmp"; + if (!has_extension(result, extension)) + result += extension; *name_changed = changed; return result; } @@ -89,15 +97,15 @@ void put_le(uint8_t *&dest, uint32_t value, size_t bytes) { *dest++ = static_cast(value >> (8 * i)); } -/// The number of bytes one row of `width` pixels takes up in the file. Rows are padded out to a -/// multiple of four bytes. -size_t bmp_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } +/// The number of bytes one row of `width` pixels takes up in a picture buffer. Rows are padded out +/// to a multiple of four bytes, which is also what a BMP file wants. +size_t padded_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } /// Write pixels out as a 24 bit BMP. The rows given start with the topmost and are `row_stride` /// bytes apart, which must leave room for a whole padded row; a BMP holds its rows the other way /// up, so they go out last first. bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) { - const size_t row_size = bmp_row_size(width); + const size_t row_size = padded_row_size(width); const size_t pixel_bytes = row_size * height; uint8_t header[BMP_HEADER_SIZE]; @@ -128,21 +136,19 @@ bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t return true; } -/// Reserve a name in the snapshot directory and write the picture to it. +/// Reserve a name in the snapshot directory and open a new file under it. /// With `exact` set the given name is the only one tried; otherwise a number is added on -/// collision. Returns true if a file was written. -bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, - bool exact) { +/// collision. Returns nullptr, having logged why, if there is no file. `path` is where it is. +FILE *create_snapshot_file(const std::string &name, bool exact, std::string &path) { const std::string dir = snapshot_dir(); std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) { ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str()); - return false; + return nullptr; } // O_EXCL guarantees we never write over a file that is already there. - std::string path; int fd = -1; for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) { path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt)); @@ -151,18 +157,18 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro break; if (errno != EEXIST) { ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno)); - return false; + return nullptr; } if (exact) { // The caller asked for this exact name, so silently writing somewhere else would be worse // than failing - a test asserting on the path would pick up a stale file. ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str()); - return false; + return nullptr; } } if (fd < 0) { ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str()); - return false; + return nullptr; } FILE *file = fdopen(fd, "wb"); @@ -170,9 +176,14 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno)); ::close(fd); ::unlink(path.c_str()); - return false; } - bool ok = write_bmp(file, pixels, width, height, row_stride); + return file; +} + +/// Close a file that is meant to be finished. `ok` says whether everything written to it went well. +/// If not, or if closing shows that the data did not all get out, say why and remove the file: a +/// truncated one would block a retry under the same name. Returns true if the file is complete. +bool close_snapshot_file(FILE *file, const std::string &path, bool ok) { int saved_errno = ok ? 0 : errno; // Closing can fail in its own right - the last of the data is still on its way out. if (fclose(file) != 0) { @@ -182,18 +193,111 @@ bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t ro } if (!ok) { ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno)); - // Leave no truncated file behind - it would block a retry under the same name. ::unlink(path.c_str()); - return false; } + return ok; +} + +/// Write pixels to a new BMP file. See create_snapshot_file() for `name` and `exact`. +bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, + bool exact) { + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + if (!close_snapshot_file(file, path, write_bmp(file, pixels, width, height, row_stride))) + return false; ESP_LOGI(TAG, "Snapshot written to %s", path.c_str()); return true; } +/// Work out the file name for a request. A name of the caller's own is made safe and is the only +/// one tried (`exact`); otherwise one is made up from the prefix and the current time. +std::string choose_name(const char *filename, const char *prefix, const char *extension, bool &exact) { + std::string name; + exact = false; + if (filename != nullptr) { + bool name_changed = false; + name = sanitise_filename(filename, extension, &name_changed); + exact = !name.empty(); + if (name_changed) { + ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, + name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); + } + } + if (!name.empty()) + return name; + + struct timespec now {}; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) + now = {}; + struct tm tm_buf {}; + if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) + tm_buf = {}; + char stamp[32]{}; + // ::strftime to be sure of the one from ; display has an unrelated member of that name + if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) + snprintf(stamp, sizeof(stamp), "unknown-time"); + char buffer[MAX_NAME_LENGTH]; + int written = snprintf(buffer, sizeof(buffer), "%s-%s-%03ld%s", prefix, stamp, now.tv_nsec / 1000000, extension); + if (written < 0 || static_cast(written) >= sizeof(buffer)) { + ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); + snprintf(buffer, sizeof(buffer), "snapshot%s", extension); + } + return buffer; +} + } // namespace -void Snapshot::take_snapshot_or_log(const char *filename) { - if (!this->take_snapshot(filename[0] != '\0' ? filename : nullptr)) { +/// An animation being written: the file, and where in the timetable it has got to. +struct Snapshot::Recording { + Recording(FILE *file, std::string path, int width, int height, uint32_t frames, float frame_rate) + : file(file), + path(std::move(path)), + gif(file, width, height), + row_stride(padded_row_size(width)), + pixels(std::make_unique(this->row_stride * height)), + frames(frames), + frame_rate(frame_rate) {} + ~Recording() { + // The file is still open only if the recording did not finish, so do not leave a partial one behind. + if (this->file != nullptr) { + fclose(this->file); + ::unlink(this->path.c_str()); + } + } + + /// When the frame with this number is due, in milliseconds after the recording began. + uint32_t due_ms(uint32_t frame) const { + return static_cast(std::lround(frame * 1000.0 / this->frame_rate)); + } + /// How long to show the frame just taken, in hundredths of a second, as a GIF file counts time. + /// Each frame's time is worked out from the start rather than by rounding its own length, so a + /// rate that does not divide evenly into hundredths still averages out to what was asked for. + unsigned delay_centiseconds() const { + auto centiseconds_at = [this](uint32_t frame) { return std::lround(frame * 100.0 / this->frame_rate); }; + return static_cast(centiseconds_at(this->frames_done + 1) - centiseconds_at(this->frames_done)); + } + + FILE *file; + std::string path; + GifWriter gif; + size_t row_stride; + std::unique_ptr pixels; + uint32_t frames; + float frame_rate; + uint32_t frames_done{0}; + uint32_t start_ms{0}; +}; + +Snapshot::Snapshot() = default; +Snapshot::~Snapshot() = default; + +// helper function since ESP_LOGW is disallowed in a header file +void Snapshot::take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate) { + const char *name = filename[0] != '\0' ? filename : nullptr; + const bool ok = frames == 0 ? this->take_snapshot(name) : this->take_animation(name, frames, frame_rate); + if (!ok) { ESP_LOGW(TAG, "snapshot.take did not write a file"); } } @@ -206,46 +310,80 @@ bool Snapshot::take_snapshot(const char *filename) { return false; } - std::string name; - bool exact = false; - if (filename != nullptr) { - bool name_changed = false; - name = sanitise_filename(filename, &name_changed); - exact = !name.empty(); - if (name_changed) { - ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, - name.empty() ? LOG_STR_LITERAL("a name made from the time") : name.c_str()); - } - } - if (name.empty()) { - struct timespec now {}; - if (clock_gettime(CLOCK_REALTIME, &now) != 0) - now = {}; - struct tm tm_buf {}; - if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) - tm_buf = {}; - char stamp[32]{}; - // ::strftime to be sure of the one from ; display has an unrelated member of that name - if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) - snprintf(stamp, sizeof(stamp), "unknown-time"); - char buffer[MAX_NAME_LENGTH]; - int written = - snprintf(buffer, sizeof(buffer), "%s-%s-%03ld.bmp", this->snapshot_prefix_, stamp, now.tv_nsec / 1000000); - if (written < 0 || static_cast(written) >= sizeof(buffer)) { - ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); - snprintf(buffer, sizeof(buffer), "snapshot.bmp"); - } - name = buffer; - } + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".bmp", exact); // Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be // written straight from the buffer. Zeroed on allocation, which is what the padding must be. - const size_t row_stride = bmp_row_size(width); + const size_t row_stride = padded_row_size(width); auto pixels = std::make_unique(row_stride * height); if (!this->capture_bgr(pixels.get(), row_stride)) return false; return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact); } +bool Snapshot::take_animation(const char *filename, uint32_t frames, float frame_rate) { + if (this->recording_ != nullptr) { + ESP_LOGW(TAG, "Already recording %s, not starting another", this->recording_->path.c_str()); + return false; + } + if (frames == 0 || !(frame_rate > 0)) { + ESP_LOGE(TAG, "Animation requested with %u frames at %.1f frames a second", frames, frame_rate); + return false; + } + const int width = this->snapshot_width(); + const int height = this->snapshot_height(); + if (width <= 0 || height <= 0 || width > GIF_MAX_SIZE || height > GIF_MAX_SIZE) { + ESP_LOGE(TAG, "Animation requested but the display is %dx%d, and a GIF holds up to %d", width, height, + GIF_MAX_SIZE); + return false; + } + + bool exact; + const std::string name = choose_name(filename, this->snapshot_prefix_, ".gif", exact); + std::string path; + FILE *file = create_snapshot_file(name, exact, path); + if (file == nullptr) + return false; + auto recording = std::make_unique(file, path, width, height, frames, frame_rate); + if (!recording->gif.write_header()) { + recording->file = nullptr; + close_snapshot_file(file, path, false); + return false; + } + ESP_LOGI(TAG, "Recording %u frames at %.1f frames a second to %s", frames, frame_rate, path.c_str()); + this->recording_ = std::move(recording); + this->recording_->start_ms = millis(); + return this->record_frame_(); +} + +bool Snapshot::record_frame_() { + Recording &recording = *this->recording_; + if (!this->capture_bgr(recording.pixels.get(), recording.row_stride)) { + // capture_bgr() has said why. Resetting removes the half-finished file. + this->recording_.reset(); + return false; + } + + bool ok = recording.gif.write_frame(recording.pixels.get(), recording.row_stride, recording.delay_centiseconds()); + recording.frames_done++; + const bool finished = recording.frames_done == recording.frames; + if (ok && finished) + ok = recording.gif.write_trailer(); + if (!ok || finished) { + ok = close_snapshot_file(std::exchange(recording.file, nullptr), recording.path, ok); + if (ok) { + ESP_LOGI(TAG, "Animation written to %s", recording.path.c_str()); + } + this->recording_.reset(); + return ok; + } + + // Aim for the time each frame is due, not a fixed gap after the last, so delays do not add up. + const int32_t wait = static_cast(recording.start_ms + recording.due_ms(recording.frames_done) - millis()); + App.scheduler.set_timeout(this, wait > 0 ? static_cast(wait) : 0, [this]() { this->record_frame_(); }); + return true; +} + } // namespace esphome::snapshot #endif diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h index 359d16536d8..4d923f6475e 100644 --- a/esphome/components/snapshot/snapshot.h +++ b/esphome/components/snapshot/snapshot.h @@ -4,6 +4,7 @@ #include #include +#include #include // Directory snapshots are written to. Normally set by codegen to a folder under .esphome; the @@ -17,11 +18,14 @@ namespace esphome::snapshot { /// Base for anything that can hand over the picture it is showing so it can be written to a file. /// /// A subclass says how big the picture is and fills in the pixels. Everything else - picking a -/// name, staying inside the snapshot directory, not writing over anything, and encoding the file - -/// is done here, so every component that can take a snapshot behaves the same way. +/// name, staying inside the snapshot directory, not writing over anything, encoding the file, and +/// timing the frames of an animation - is done here, so every component that can take a snapshot +/// behaves the same way. class Snapshot { public: - virtual ~Snapshot() = default; + // Declared here and defined where Recording is complete, which unique_ptr needs. + Snapshot(); + virtual ~Snapshot(); /// Set the word generated names start with. Codegen passes the component id, so with more than /// one display in a device it is clear which one a file came from. @@ -33,10 +37,20 @@ class Snapshot { /// already there is never written over. Returns true if a file was written. bool take_snapshot(const char *filename); + /// Record what is shown as an animated GIF file in the snapshot directory. + /// + /// The first frame is taken now and the rest follow at `frame_rate` frames a second, in the + /// background. Names work as for take_snapshot(), with ".gif" in place of ".bmp". Only one + /// recording can run at a time. Returns true if the recording started. + bool take_animation(const char *filename, uint32_t frames, float frame_rate); + /// Take a snapshot for an automation: an empty name means a generated one, and a snapshot - /// that wrote nothing is logged. - void take_snapshot_or_log(const char *filename); - void take_snapshot_or_log(const std::string &filename) { this->take_snapshot_or_log(filename.c_str()); } + /// that wrote nothing is logged. A `frames` of 0 takes a single picture; otherwise an + /// animation is recorded. + void take_snapshot_or_log(const char *filename, uint32_t frames, float frame_rate); + void take_snapshot_or_log(const std::string &filename, uint32_t frames, float frame_rate) { + this->take_snapshot_or_log(filename.c_str(), frames, frame_rate); + } protected: /// Width of the picture in pixels. @@ -49,6 +63,15 @@ class Snapshot { virtual bool capture_bgr(uint8_t *dest, size_t row_stride) = 0; const char *snapshot_prefix_{"snapshot"}; + + private: + struct Recording; + + /// Add a frame to the recording and arrange for the next one. Returns false if the recording + /// had to be given up. + bool record_frame_(); + + std::unique_ptr recording_; }; } // namespace esphome::snapshot diff --git a/tests/components/snapshot/common.yaml b/tests/components/snapshot/common.yaml index 9ce2d33a875..e9c59437776 100644 --- a/tests/components/snapshot/common.yaml +++ b/tests/components/snapshot/common.yaml @@ -26,6 +26,11 @@ esphome: - snapshot.take: id: snapshot_rotated filename: !lambda 'return "rotated.bmp";' + - snapshot.take: + id: snapshot_display + filename: animation.gif + frames: 5 + frame_rate: 10fps interval: # A generated name has the time in it, so this one can repeat. diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp new file mode 100644 index 00000000000..d63101357a2 --- /dev/null +++ b/tests/components/snapshot/snapshot_test.cpp @@ -0,0 +1,125 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/snapshot/snapshot.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::snapshot::testing { +namespace fs = std::filesystem; + +/// A display of a fixed size that can be told to fail a capture. +class FakeSnapshot : public Snapshot { + public: + /// The capture with this number (counting from 1) fails. Zero means none do. + int fail_on{0}; + int captures{0}; + + protected: + int snapshot_width() override { return 8; } + int snapshot_height() override { return 4; } + bool capture_bgr(uint8_t *dest, size_t row_stride) override { + this->captures++; + if (this->captures == this->fail_on) + return false; + for (int y = 0; y != 4; y++) { + for (int x = 0; x != 8 * 3; x++) + dest[y * row_stride + x] = static_cast(x * 7 + y * 31 + this->captures); + } + return true; + } +}; + +class SnapshotAnimationTest : public ::testing::Test { + protected: + void SetUp() override { + this->dir_ = fs::temp_directory_path() / "esphome_snapshot_test"; + fs::remove_all(this->dir_); + fs::create_directories(this->dir_); + setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); + // The test main does not construct App as generated code does, and recording needs its scheduler. + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_snapshot", 10, "", 0); + } + void TearDown() override { + unsetenv("ESPHOME_SNAPSHOT_DIR"); + fs::remove_all(this->dir_); + } + + /// Names of the files in the snapshot directory, in order. + std::vector files() const { + std::vector names; + for (const auto &entry : fs::directory_iterator(this->dir_)) + names.push_back(entry.path().filename().string()); + std::sort(names.begin(), names.end()); + return names; + } + + /// Let the scheduler run for long enough that a fast recording has finished. + static void run_scheduler() { + for (int i = 0; i != 50; i++) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + fs::path dir_; +}; + +TEST_F(SnapshotAnimationTest, FailedFirstCaptureLeavesNoFile) { + FakeSnapshot display; + display.fail_on = 1; + EXPECT_FALSE(display.take_animation("first.gif", 3, 50.0f)); + EXPECT_TRUE(this->files().empty()); +} + +TEST_F(SnapshotAnimationTest, FailedCaptureMidRecordingRemovesPartialFile) { + FakeSnapshot display; + display.fail_on = 2; + ASSERT_TRUE(display.take_animation("partial.gif", 3, 50.0f)); + // The first frame is already in the file. + EXPECT_EQ(this->files(), std::vector{"partial.gif"}); + + run_scheduler(); + EXPECT_TRUE(this->files().empty()); + // The failure ends the recording, so the display can record again. + display.fail_on = 0; + EXPECT_TRUE(display.take_animation("again.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"again.gif"}); +} + +TEST_F(SnapshotAnimationTest, SecondRecordingIsRefusedUntilTheFirstEnds) { + FakeSnapshot display; + ASSERT_TRUE(display.take_animation("one.gif", 3, 50.0f)); + EXPECT_FALSE(display.take_animation("two.gif", 3, 50.0f)); + EXPECT_EQ(this->files(), std::vector{"one.gif"}); + + run_scheduler(); + std::ifstream in(this->dir_ / "one.gif", std::ios::binary); + std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + ASSERT_FALSE(data.empty()); + EXPECT_EQ(data.back(), ';'); // the GIF trailer: the recording finished + + EXPECT_TRUE(display.take_animation("two.gif", 1, 50.0f)); + EXPECT_EQ(this->files(), (std::vector{"one.gif", "two.gif"})); +} + +TEST_F(SnapshotAnimationTest, RejectsNoFramesAndNoFrameRate) { + FakeSnapshot display; + EXPECT_FALSE(display.take_animation("none.gif", 0, 10.0f)); + EXPECT_FALSE(display.take_animation("none.gif", 3, 0.0f)); + EXPECT_TRUE(this->files().empty()); +} + +} // namespace esphome::snapshot::testing +#endif diff --git a/tests/integration/fixtures/snapshot_display.yaml b/tests/integration/fixtures/snapshot_display.yaml index d10af098069..894ad03d40d 100644 --- a/tests/integration/fixtures/snapshot_display.yaml +++ b/tests/integration/fixtures/snapshot_display.yaml @@ -14,6 +14,47 @@ api: id: snapshot_display filename: !lambda return name; + - action: take_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 5 + frame_rate: 20fps + + - action: take_noise_snapshot + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + + # A busy picture needs more compression codes than a GIF dictionary holds, so recording it makes + # the encoder widen its codes and start the dictionary over. + - action: take_noise_animation + variables: + name: string + then: + - snapshot.take: + id: noise_display + filename: !lambda return name; + frames: 3 + frame_rate: 20fps + + # Slow enough that a second request arrives while this one is still recording. + - action: take_slow_animation + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + frames: 3 + frame_rate: 2fps + logger: level: DEBUG @@ -26,3 +67,23 @@ display: dimensions: width: 101 height: 64 + + # Every pixel is one of 256 colours picked by a hash of its position, so the picture is + # effectively random but fits a GIF palette exactly. + - platform: snapshot + id: noise_display + update_interval: 1s + dimensions: + width: 200 + height: 150 + lambda: |- + for (int y = 0; y != it.get_height(); y++) { + for (int x = 0; x != it.get_width(); x++) { + uint32_t h = static_cast(x) * 73856093u ^ static_cast(y) * 19349663u; + h ^= h >> 13; + h *= 0x5bd1e995u; + h ^= h >> 15; + uint8_t c = h & 0xFF; + it.draw_pixel_at(x, y, Color(c & 0xE0, (c << 3) & 0xE0, (c << 6) & 0xC0)); + } + } diff --git a/tests/integration/test_snapshot_display.py b/tests/integration/test_snapshot_display.py index 771cf0cf7d9..6f720ef30e3 100644 --- a/tests/integration/test_snapshot_display.py +++ b/tests/integration/test_snapshot_display.py @@ -3,19 +3,66 @@ from __future__ import annotations import asyncio +import io from pathlib import Path from aioesphomeapi import LogLevel +from PIL import Image, UnidentifiedImageError import pytest -from .bmp_utils import capture_when_drawn, wait_for_bmp +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp from .types import APIClientConnectedFactory, RunCompiledFunction WIDTH = 101 HEIGHT = 64 +ANIMATION_FRAMES = 5 +# The fixture asks for 20 frames a second, and a GIF counts time in milliseconds here. +ANIMATION_FRAME_MS = 50 + +NOISE_WIDTH = 200 +NOISE_HEIGHT = 150 +NOISE_FRAMES = 3 + # Part of the message the writer logs when it will not write over a file that is already there. REFUSAL_MESSAGE = b"not overwriting" +# Part of the message logged when an animation is asked for while another is still being recorded. +BUSY_MESSAGE = b"Already recording" + + +async def wait_for_gif(path: Path, frames: int, timeout: float = 5.0) -> Image.Image: + """Wait for a complete animated GIF with the given number of frames and return it. + + The file exists from the moment the recording starts and grows a frame at a time, so keep + reading until it holds all of them and ends with the GIF trailer. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + data = path.read_bytes() + # Open the bytes just read, so the trailer check and the decode see the same file. + image = Image.open(io.BytesIO(data)) + if data.endswith(b";") and image.n_frames == frames: + # Decoding every frame proves the compressed data is all there and valid. + for frame in range(frames): + image.seek(frame) + image.load() + image.seek(0) + return image + except (FileNotFoundError, UnidentifiedImageError, OSError, EOFError): + pass + await asyncio.sleep(0.05) + raise AssertionError( + f"no complete {frames} frame GIF appeared at {path} within {timeout}s" + ) + + +def bmp_as_image(bmp: Bmp) -> Image.Image: + """The picture in a decoded BMP, as an RGB image.""" + return Image.frombytes( + "RGB", (bmp.width, bmp.height), bmp.pixels, "raw", "BGR", 0, -1 + ) @pytest.mark.asyncio @@ -39,12 +86,50 @@ async def test_snapshot_display( async def take(name: str) -> None: await client.execute_service(service, {"name": name}) + animation_service = next(s for s in services if s.name == "take_animation") + # The test card is drawn in several colours, so once it is on the screen the picture is # not one flat shade. Capturing until that is true waits out the first update rather than # racing it. image, capture = await capture_when_drawn(take, snapshot_dir) assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + # Asking for frames records a GIF: every frame is the size of the display and lasts as long + # as the frame rate says. The test card does not move, so every frame is the picture + # captured above. + await client.execute_service(animation_service, {"name": "movie"}) + movie = await wait_for_gif(snapshot_dir / "movie.gif", ANIMATION_FRAMES) + assert movie.size == (WIDTH, HEIGHT) + expected = bmp_as_image(image) + for frame in range(ANIMATION_FRAMES): + movie.seek(frame) + assert movie.info["duration"] == ANIMATION_FRAME_MS + assert movie.convert("RGB").tobytes() == expected.tobytes() + + # A picture of 256 colours in no pattern makes a long run of compression codes, so recording + # it passes the point where the code width grows and where the dictionary is started over. + # The picture uses no more than 256 colours, so it is stored exactly and every frame must + # come back identical to the one captured as a BMP. + noise_service = next(s for s in services if s.name == "take_noise_snapshot") + noise_animation = next(s for s in services if s.name == "take_noise_animation") + + async def take_noise(name: str) -> None: + await client.execute_service(noise_service, {"name": name}) + + noise, _ = await capture_when_drawn(take_noise, snapshot_dir, "noisedrawn") + assert (noise.width, noise.height) == (NOISE_WIDTH, NOISE_HEIGHT) + noise_expected = bmp_as_image(noise) + assert ( + len(noise_expected.getcolors(NOISE_WIDTH * NOISE_HEIGHT)) > 200 + ) # a busy picture + await client.execute_service(noise_animation, {"name": "noise"}) + noise_movie = await wait_for_gif( + snapshot_dir / "noise.gif", NOISE_FRAMES, timeout=15.0 + ) + for frame in range(NOISE_FRAMES): + noise_movie.seek(frame) + assert noise_movie.convert("RGB").tobytes() == noise_expected.tobytes() + # An extension is only added when there is not one already, whatever its case. await take("UPPER.BMP") await wait_for_bmp(snapshot_dir / "UPPER.BMP") @@ -54,18 +139,30 @@ async def test_snapshot_display( await take("../escape") await wait_for_bmp(snapshot_dir / ".._escape.bmp") - # A second capture under a name already used must fail rather than write over the first. - # Wait for the device to report the refusal: on its own, an unchanged file cannot tell a - # refusal apart from a request the device has not got to yet, so a regression that wrote - # over the file could still pass on a busy machine. + # The device says so when it refuses a request. Waiting for that message is the only way to + # tell a refusal from a request the device has not got to yet: an unchanged file, or a file + # that never appears, would look the same on a busy machine. refused = asyncio.Event() + busy = asyncio.Event() def on_log(msg) -> None: if REFUSAL_MESSAGE in msg.message: refused.set() + if BUSY_MESSAGE in msg.message: + busy.set() client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG) + # A display records one animation at a time. A second request made while the first is still + # running is refused and leaves no file, and the first one carries on to the end. + slow_animation = next(s for s in services if s.name == "take_slow_animation") + await client.execute_service(slow_animation, {"name": "slow"}) + await client.execute_service(slow_animation, {"name": "second"}) + await asyncio.wait_for(busy.wait(), timeout=10.0) + assert not (snapshot_dir / "second.gif").exists() + await wait_for_gif(snapshot_dir / "slow.gif", 3) + + # A second capture under a name already used must fail rather than write over the first. before = capture.read_bytes() await take(capture.name) await asyncio.wait_for(refused.wait(), timeout=10.0) @@ -73,6 +170,12 @@ async def test_snapshot_display( # Nothing beyond what was asked for, leaving out however many captures it took to wait # for the first frame. written = sorted( - p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-") + p.name for p in snapshot_dir.iterdir() if "drawn-" not in p.name ) - assert written == [".._escape.bmp", "UPPER.BMP"] + assert written == [ + ".._escape.bmp", + "UPPER.BMP", + "movie.gif", + "noise.gif", + "slow.gif", + ] From 1ea45be19e272091bdf919ffac836eb4485c19b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 12:12:05 +0100 Subject: [PATCH 570/759] [const] Share CONF_HOST (#19716) --- esphome/components/const/__init__.py | 1 + esphome/components/statsd/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index b8bb9db8bf5..faa2a376709 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -23,6 +23,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOST = "host" CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index b5a0586e31d..58d4be97fae 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor, sensor +from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, @@ -14,7 +15,6 @@ AUTO_LOAD = ["socket"] CODEOWNERS = ["@Links2004"] DEPENDENCIES = ["network"] -CONF_HOST = "host" CONF_PREFIX = "prefix" statsd_component_ns = cg.esphome_ns.namespace("statsd") From a7e0c0f8a2308b74c7a3cdde14d2319ba18003d1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:19:38 +1000 Subject: [PATCH 571/759] [light][lvgl] Add color names for light states (#19712) --- esphome/components/const/css_colors.py | 152 ++++++++++++++++ esphome/components/light/automation.py | 55 +++++- esphome/components/lvgl/lv_validation.py | 162 +----------------- .../component_tests/light/test_color_name.py | 119 +++++++++++++ tests/components/light/common.yaml | 26 +++ 5 files changed, 356 insertions(+), 158 deletions(-) create mode 100644 esphome/components/const/css_colors.py create mode 100644 tests/component_tests/light/test_color_name.py diff --git a/esphome/components/const/css_colors.py b/esphome/components/const/css_colors.py new file mode 100644 index 00000000000..a20dc577292 --- /dev/null +++ b/esphome/components/const/css_colors.py @@ -0,0 +1,152 @@ +"""Standard CSS named colors, as 0xRRGGBB values.""" + +CSS_COLORS: dict[str, int] = { + "aliceblue": 0xF0F8FF, + "antiquewhite": 0xFAEBD7, + "aqua": 0x00FFFF, + "aquamarine": 0x7FFFD4, + "azure": 0xF0FFFF, + "beige": 0xF5F5DC, + "bisque": 0xFFE4C4, + "black": 0x000000, + "blanchedalmond": 0xFFEBCD, + "blue": 0x0000FF, + "blueviolet": 0x8A2BE2, + "brown": 0xA52A2A, + "burlywood": 0xDEB887, + "cadetblue": 0x5F9EA0, + "chartreuse": 0x7FFF00, + "chocolate": 0xD2691E, + "coral": 0xFF7F50, + "cornflowerblue": 0x6495ED, + "cornsilk": 0xFFF8DC, + "crimson": 0xDC143C, + "cyan": 0x00FFFF, + "darkblue": 0x00008B, + "darkcyan": 0x008B8B, + "darkgoldenrod": 0xB8860B, + "darkgray": 0xA9A9A9, + "darkgreen": 0x006400, + "darkgrey": 0xA9A9A9, + "darkkhaki": 0xBDB76B, + "darkmagenta": 0x8B008B, + "darkolivegreen": 0x556B2F, + "darkorange": 0xFF8C00, + "darkorchid": 0x9932CC, + "darkred": 0x8B0000, + "darksalmon": 0xE9967A, + "darkseagreen": 0x8FBC8F, + "darkslateblue": 0x483D8B, + "darkslategray": 0x2F4F4F, + "darkslategrey": 0x2F4F4F, + "darkturquoise": 0x00CED1, + "darkviolet": 0x9400D3, + "deeppink": 0xFF1493, + "deepskyblue": 0x00BFFF, + "dimgray": 0x696969, + "dimgrey": 0x696969, + "dodgerblue": 0x1E90FF, + "firebrick": 0xB22222, + "floralwhite": 0xFFFAF0, + "forestgreen": 0x228B22, + "fuchsia": 0xFF00FF, + "gainsboro": 0xDCDCDC, + "ghostwhite": 0xF8F8FF, + "goldenrod": 0xDAA520, + "gold": 0xFFD700, + "gray": 0x808080, + "green": 0x008000, + "greenyellow": 0xADFF2F, + "grey": 0x808080, + "honeydew": 0xF0FFF0, + "hotpink": 0xFF69B4, + "indianred": 0xCD5C5C, + "indigo": 0x4B0082, + "ivory": 0xFFFFF0, + "khaki": 0xF0E68C, + "lavenderblush": 0xFFF0F5, + "lavender": 0xE6E6FA, + "lawngreen": 0x7CFC00, + "lemonchiffon": 0xFFFACD, + "lightblue": 0xADD8E6, + "lightcoral": 0xF08080, + "lightcyan": 0xE0FFFF, + "lightgoldenrodyellow": 0xFAFAD2, + "lightgray": 0xD3D3D3, + "lightgreen": 0x90EE90, + "lightgrey": 0xD3D3D3, + "lightpink": 0xFFB6C1, + "lightsalmon": 0xFFA07A, + "lightseagreen": 0x20B2AA, + "lightskyblue": 0x87CEFA, + "lightslategray": 0x778899, + "lightslategrey": 0x778899, + "lightsteelblue": 0xB0C4DE, + "lightyellow": 0xFFFFE0, + "lime": 0x00FF00, + "limegreen": 0x32CD32, + "linen": 0xFAF0E6, + "magenta": 0xFF00FF, + "maroon": 0x800000, + "mediumaquamarine": 0x66CDAA, + "mediumblue": 0x0000CD, + "mediumorchid": 0xBA55D3, + "mediumpurple": 0x9370DB, + "mediumseagreen": 0x3CB371, + "mediumslateblue": 0x7B68EE, + "mediumspringgreen": 0x00FA9A, + "mediumturquoise": 0x48D1CC, + "mediumvioletred": 0xC71585, + "midnightblue": 0x191970, + "mintcream": 0xF5FFFA, + "mistyrose": 0xFFE4E1, + "moccasin": 0xFFE4B5, + "navajowhite": 0xFFDEAD, + "navy": 0x000080, + "oldlace": 0xFDF5E6, + "olive": 0x808000, + "olivedrab": 0x6B8E23, + "orange": 0xFFA500, + "orangered": 0xFF4500, + "orchid": 0xDA70D6, + "palegoldenrod": 0xEEE8AA, + "palegreen": 0x98FB98, + "paleturquoise": 0xAFEEEE, + "palevioletred": 0xDB7093, + "papayawhip": 0xFFEFD5, + "peachpuff": 0xFFDAB9, + "peru": 0xCD853F, + "pink": 0xFFC0CB, + "plum": 0xDDA0DD, + "powderblue": 0xB0E0E6, + "purple": 0x800080, + "rebeccapurple": 0x663399, + "red": 0xFF0000, + "rosybrown": 0xBC8F8F, + "royalblue": 0x4169E1, + "saddlebrown": 0x8B4513, + "salmon": 0xFA8072, + "sandybrown": 0xF4A460, + "seagreen": 0x2E8B57, + "seashell": 0xFFF5EE, + "sienna": 0xA0522D, + "silver": 0xC0C0C0, + "skyblue": 0x87CEEB, + "slateblue": 0x6A5ACD, + "slategray": 0x708090, + "slategrey": 0x708090, + "snow": 0xFFFAFA, + "springgreen": 0x00FF7F, + "steelblue": 0x4682B4, + "tan": 0xD2B48C, + "teal": 0x008080, + "thistle": 0xD8BFD8, + "tomato": 0xFF6347, + "turquoise": 0x40E0D0, + "violet": 0xEE82EE, + "wheat": 0xF5DEB3, + "white": 0xFFFFFF, + "whitesmoke": 0xF5F5F5, + "yellow": 0xFFFF00, + "yellowgreen": 0x9ACD32, +} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index c938742dac3..a616d26571d 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,8 +1,10 @@ from collections.abc import Callable +import logging from typing import Any, NamedTuple from esphome import automation import esphome.codegen as cg +from esphome.components.const.css_colors import CSS_COLORS from esphome.config import path_context import esphome.config_validation as cv from esphome.const import ( @@ -10,6 +12,7 @@ from esphome.const import ( CONF_BRIGHTNESS, CONF_BRIGHTNESS_LIMITS, CONF_COLD_WHITE, + CONF_COLOR, CONF_COLOR_BRIGHTNESS, CONF_COLOR_MODE, CONF_COLOR_TEMPERATURE, @@ -46,6 +49,8 @@ from .types import ( ToggleAction, ) +_LOGGER = logging.getLogger(__name__) + CONF_INCLUDE_NONE = "include_none" _STATE_ON_OFF = cv.one_of("ON", "OFF", upper=True) @@ -141,6 +146,50 @@ LIGHT_STATE_FIELDS: tuple[LightStateField, ...] = ( ), ) + +@schema_extractor("one_of") +def validate_color(value: Any) -> str | int: + """Validate a CSS color name or a 0xRRGGBB value.""" + if value == SCHEMA_EXTRACT: + return ["CSS color name", "hex color value"] + if isinstance(value, int) or ( + isinstance(value, str) and value.lower().startswith("0x") + ): + return cv.hex_int_range(0, 0xFFFFFF)(value) + return cv.one_of(*CSS_COLORS, lower=True)(value) + + +COLOR_SCHEMA: dict[cv.Optional, Any] = {cv.Optional(CONF_COLOR): validate_color} + + +def color_to_rgb(config: ConfigType) -> ConfigType: + """Replace a `color` CSS name or 0xRRGGBB value with red, green and blue values. + + The light scales its color so the brightest channel is at full level, so a dark + color is given as a full-level color plus a color brightness. + """ + if (color := config.pop(CONF_COLOR, None)) is None: + return config + if any(key in config for key in (CONF_RED, CONF_GREEN, CONF_BLUE)): + raise cv.Invalid( + f"'{CONF_COLOR}' cannot be used with '{CONF_RED}', '{CONF_GREEN}' or '{CONF_BLUE}'" + ) + rgb = color if isinstance(color, int) else CSS_COLORS[color] + channels = (rgb >> 16 & 0xFF, rgb >> 8 & 0xFF, rgb & 0xFF) + peak = max(channels) + if CONF_COLOR_BRIGHTNESS not in config: + config[CONF_COLOR_BRIGHTNESS] = peak / 255 + elif peak < 0xFF: + _LOGGER.warning( + "'%s' overrides the brightness of color '%s'", + CONF_COLOR_BRIGHTNESS, + f"0x{color:06X}" if isinstance(color, int) else color, + ) + for key, value in zip((CONF_RED, CONF_GREEN, CONF_BLUE), channels, strict=True): + config[key] = value / peak if peak else 0.0 + return config + + LIGHT_STATE_SCHEMA = cv.Schema( { cv.Optional(field.conf_key): ( @@ -148,7 +197,8 @@ LIGHT_STATE_SCHEMA = cv.Schema( ) for field in LIGHT_STATE_FIELDS } -) +).extend(COLOR_SCHEMA) +LIGHT_STATE_SCHEMA.add_extra(color_to_rgb) LIGHT_CONTROL_ACTION_SCHEMA = LIGHT_STATE_SCHEMA.extend( { @@ -405,7 +455,8 @@ LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA = cv.Schema( cv.Optional(CONF_BLUE): cv.templatable(cv.percentage), cv.Optional(CONF_WHITE): cv.templatable(cv.percentage), } -) +).extend(COLOR_SCHEMA) +LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA.add_extra(color_to_rgb) @automation.register_action( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 6f86e49e511..b3285afa1af 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -4,6 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw +from esphome.components.const.css_colors import CSS_COLORS from esphome.components.font import Font from esphome.components.image import Image_ import esphome.config_validation as cv @@ -64,170 +65,19 @@ opacity = LValidator( animatable=True, ) -COLOR_NAMES = { - "aliceblue": 0xF0F8FF, - "antiquewhite": 0xFAEBD7, - "aqua": 0x00FFFF, - "aquamarine": 0x7FFFD4, - "azure": 0xF0FFFF, - "beige": 0xF5F5DC, - "bisque": 0xFFE4C4, - "black": 0x000000, - "blanchedalmond": 0xFFEBCD, - "blue": 0x0000FF, - "blueviolet": 0x8A2BE2, - "brown": 0xA52A2A, - "burlywood": 0xDEB887, - "cadetblue": 0x5F9EA0, - "chartreuse": 0x7FFF00, - "chocolate": 0xD2691E, - "coral": 0xFF7F50, - "cornflowerblue": 0x6495ED, - "cornsilk": 0xFFF8DC, - "crimson": 0xDC143C, - "cyan": 0x00FFFF, - "darkblue": 0x00008B, - "darkcyan": 0x008B8B, - "darkgoldenrod": 0xB8860B, - "darkgray": 0xA9A9A9, - "darkgreen": 0x006400, - "darkgrey": 0xA9A9A9, - "darkkhaki": 0xBDB76B, - "darkmagenta": 0x8B008B, - "darkolivegreen": 0x556B2F, - "darkorange": 0xFF8C00, - "darkorchid": 0x9932CC, - "darkred": 0x8B0000, - "darksalmon": 0xE9967A, - "darkseagreen": 0x8FBC8F, - "darkslateblue": 0x483D8B, - "darkslategray": 0x2F4F4F, - "darkslategrey": 0x2F4F4F, - "darkturquoise": 0x00CED1, - "darkviolet": 0x9400D3, - "deeppink": 0xFF1493, - "deepskyblue": 0x00BFFF, - "dimgray": 0x696969, - "dimgrey": 0x696969, - "dodgerblue": 0x1E90FF, - "firebrick": 0xB22222, - "floralwhite": 0xFFFAF0, - "forestgreen": 0x228B22, - "fuchsia": 0xFF00FF, - "gainsboro": 0xDCDCDC, - "ghostwhite": 0xF8F8FF, - "goldenrod": 0xDAA520, - "gold": 0xFFD700, - "gray": 0x808080, - "green": 0x008000, - "greenyellow": 0xADFF2F, - "grey": 0x808080, - "honeydew": 0xF0FFF0, - "hotpink": 0xFF69B4, - "indianred": 0xCD5C5C, - "indigo": 0x4B0082, - "ivory": 0xFFFFF0, - "khaki": 0xF0E68C, - "lavenderblush": 0xFFF0F5, - "lavender": 0xE6E6FA, - "lawngreen": 0x7CFC00, - "lemonchiffon": 0xFFFACD, - "lightblue": 0xADD8E6, - "lightcoral": 0xF08080, - "lightcyan": 0xE0FFFF, - "lightgoldenrodyellow": 0xFAFAD2, - "lightgray": 0xD3D3D3, - "lightgreen": 0x90EE90, - "lightgrey": 0xD3D3D3, - "lightpink": 0xFFB6C1, - "lightsalmon": 0xFFA07A, - "lightseagreen": 0x20B2AA, - "lightskyblue": 0x87CEFA, - "lightslategray": 0x778899, - "lightslategrey": 0x778899, - "lightsteelblue": 0xB0C4DE, - "lightyellow": 0xFFFFE0, - "lime": 0x00FF00, - "limegreen": 0x32CD32, - "linen": 0xFAF0E6, - "magenta": 0xFF00FF, - "maroon": 0x800000, - "mediumaquamarine": 0x66CDAA, - "mediumblue": 0x0000CD, - "mediumorchid": 0xBA55D3, - "mediumpurple": 0x9370DB, - "mediumseagreen": 0x3CB371, - "mediumslateblue": 0x7B68EE, - "mediumspringgreen": 0x00FA9A, - "mediumturquoise": 0x48D1CC, - "mediumvioletred": 0xC71585, - "midnightblue": 0x191970, - "mintcream": 0xF5FFFA, - "mistyrose": 0xFFE4E1, - "moccasin": 0xFFE4B5, - "navajowhite": 0xFFDEAD, - "navy": 0x000080, - "oldlace": 0xFDF5E6, - "olive": 0x808000, - "olivedrab": 0x6B8E23, - "orange": 0xFFA500, - "orangered": 0xFF4500, - "orchid": 0xDA70D6, - "palegoldenrod": 0xEEE8AA, - "palegreen": 0x98FB98, - "paleturquoise": 0xAFEEEE, - "palevioletred": 0xDB7093, - "papayawhip": 0xFFEFD5, - "peachpuff": 0xFFDAB9, - "peru": 0xCD853F, - "pink": 0xFFC0CB, - "plum": 0xDDA0DD, - "powderblue": 0xB0E0E6, - "purple": 0x800080, - "rebeccapurple": 0x663399, - "red": 0xFF0000, - "rosybrown": 0xBC8F8F, - "royalblue": 0x4169E1, - "saddlebrown": 0x8B4513, - "salmon": 0xFA8072, - "sandybrown": 0xF4A460, - "seagreen": 0x2E8B57, - "seashell": 0xFFF5EE, - "sienna": 0xA0522D, - "silver": 0xC0C0C0, - "skyblue": 0x87CEEB, - "slateblue": 0x6A5ACD, - "slategray": 0x708090, - "slategrey": 0x708090, - "snow": 0xFFFAFA, - "springgreen": 0x00FF7F, - "steelblue": 0x4682B4, - "tan": 0xD2B48C, - "teal": 0x008080, - "thistle": 0xD8BFD8, - "tomato": 0xFF6347, - "turquoise": 0x40E0D0, - "violet": 0xEE82EE, - "wheat": 0xF5DEB3, - "white": 0xFFFFFF, - "whitesmoke": 0xF5F5F5, - "yellow": 0xFFFF00, - "yellowgreen": 0x9ACD32, -} - @schema_extractor("one_of") def color(value): if value == SCHEMA_EXTRACT: return ["hex color value", "color ID"] - return cv.Any(cv.int_, cv.one_of(*COLOR_NAMES, lower=True), cv.use_id(ColorStruct))( + return cv.Any(cv.int_, cv.one_of(*CSS_COLORS, lower=True), cv.use_id(ColorStruct))( value ) def get_component_colors(value): - if isinstance(value, str) and value in COLOR_NAMES: - value = COLOR_NAMES[value] + if isinstance(value, str) and value in CSS_COLORS: + value = CSS_COLORS[value] if isinstance(value, int): return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): @@ -254,8 +104,8 @@ class LvColor(LValidator): ) def __getattr__(self, item): - if item in COLOR_NAMES: - return color_retmapper(COLOR_NAMES[item]) + if item in CSS_COLORS: + return color_retmapper(CSS_COLORS[item]) raise AttributeError(item) diff --git a/tests/component_tests/light/test_color_name.py b/tests/component_tests/light/test_color_name.py new file mode 100644 index 00000000000..4f6fda686e4 --- /dev/null +++ b/tests/component_tests/light/test_color_name.py @@ -0,0 +1,119 @@ +"""Tests for the light `color` option.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.automation import ( + LIGHT_CONTROL_ACTION_SCHEMA, + LIGHT_STATE_SCHEMA, +) +from esphome.const import ( + CONF_BLUE, + CONF_COLOR_BRIGHTNESS, + CONF_GREEN, + CONF_ID, + CONF_RED, +) + +LOGGER_NAME: str = "esphome.components.light.automation" + + +def test_color_name_sets_rgb() -> None: + result = LIGHT_STATE_SCHEMA({"color": "Tomato"}) + assert "color" not in result + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == pytest.approx(0x63 / 0xFF) + assert result[CONF_BLUE] == pytest.approx(0x47 / 0xFF) + assert result[CONF_COLOR_BRIGHTNESS] == 1.0 + + +def test_color_name_in_control_action() -> None: + result = LIGHT_CONTROL_ACTION_SCHEMA({CONF_ID: "test_light", "color": "blue"}) + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 1.0) + + +def test_unknown_color_name() -> None: + with pytest.raises(cv.Invalid, match="notacolor"): + LIGHT_STATE_SCHEMA({"color": "notacolor"}) + + +def test_color_name_conflicts_with_rgb() -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + LIGHT_STATE_SCHEMA({"color": "red", CONF_GREEN: 0.5}) + + +def test_dark_color_sets_color_brightness() -> None: + result = LIGHT_STATE_SCHEMA({"color": "darkred"}) + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == 0.0 + assert result[CONF_BLUE] == 0.0 + assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF) + + +def test_black_sets_zero_color_brightness() -> None: + result = LIGHT_STATE_SCHEMA({"color": "black"}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.0 + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0) + + +def test_explicit_color_brightness_wins(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + result = LIGHT_STATE_SCHEMA({"color": "darkred", CONF_COLOR_BRIGHTNESS: 0.25}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.25 + assert "overrides the brightness of color 'darkred'" in caplog.text + + +def test_explicit_color_brightness_full_color( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + result = LIGHT_STATE_SCHEMA({"color": "red", CONF_COLOR_BRIGHTNESS: 0.25}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.25 + assert not [r for r in caplog.records if r.name == LOGGER_NAME] + + +@pytest.mark.parametrize("value", [0x223344, "0x223344", "0X223344"]) +def test_hex_color(value: int | str) -> None: + result = LIGHT_STATE_SCHEMA({"color": value}) + assert "color" not in result + assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x44 / 0xFF) + assert result[CONF_BLUE] == 1.0 + assert result[CONF_RED] == pytest.approx(0x22 / 0x44) + assert result[CONF_GREEN] == pytest.approx(0x33 / 0x44) + + +def test_hex_color_full_level() -> None: + result = LIGHT_STATE_SCHEMA({"color": 0xFF8000}) + assert result[CONF_COLOR_BRIGHTNESS] == 1.0 + assert result[CONF_RED] == 1.0 + assert result[CONF_GREEN] == pytest.approx(0x80 / 0xFF) + assert result[CONF_BLUE] == 0.0 + + +def test_hex_color_black() -> None: + result = LIGHT_STATE_SCHEMA({"color": 0}) + assert result[CONF_COLOR_BRIGHTNESS] == 0.0 + assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (0, 0, 0) + + +@pytest.mark.parametrize("value", [0x1000000, -1, "0xGG", "0x1000000"]) +def test_hex_color_invalid(value: int | str) -> None: + with pytest.raises(cv.Invalid): + LIGHT_STATE_SCHEMA({"color": value}) + + +def test_hex_color_conflicts_with_rgb() -> None: + with pytest.raises(cv.Invalid, match="cannot be used with"): + LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_RED: 0.5}) + + +def test_explicit_color_brightness_warning_names_hex_color( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger=LOGGER_NAME): + LIGHT_STATE_SCHEMA({"color": 0x223344, CONF_COLOR_BRIGHTNESS: 0.25}) + assert "overrides the brightness of color '0x223344'" in caplog.text diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e1135098e4d..9e3242f9a19 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -85,6 +85,12 @@ esphome: red: 100% green: 100% blue: 1.0 + - light.turn_on: + id: test_rgb_light + color: Tomato + - light.turn_on: + id: test_rgb_light + color: 0x223344 - light.control: id: test_monochromatic_light state: on @@ -157,6 +163,17 @@ esphome: green: 100% blue: 0% transition_length: 1s + - light.addressable_set: + id: test_addressable_transition + range_from: 0 + range_to: 4 + color: darkred + - light.addressable_set: + id: test_addressable_transition + color: tomato + - light.addressable_set: + id: test_addressable_transition + color: 0x223344 # Exercise light actions inside a trigger with non-empty Ts (number on_value # passes float). @@ -336,6 +353,15 @@ light: red: 100% green: 50% blue: 50% + # Test initial_state: with a color name (a dark color also sets color_brightness) + - platform: rgb + id: test_rgb_light_initial_state_color + name: RGB Light Initial State Color + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + initial_state: + color: darkred # Test initial_state: with every templatable field set via !lambda (color_mode is # the only field that isn't templatable, so it's the only one left as a literal) - platform: rgbww From 72ac32a2c4cf247b53d4383f7af7bf8fea95fa7c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:41:52 +0100 Subject: [PATCH 572/759] =?UTF-8?q?[gree]=20Encode=20temperature=20as=20of?= =?UTF-8?q?fset=20from=2016=C2=B0C=20(#19718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/components/gree/gree.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 6add618ecb8..e59f186521b 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -226,7 +226,7 @@ uint8_t GreeClimate::vertical_swing_() { } uint8_t GreeClimate::temperature_() { - return (uint8_t) roundf(clamp(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)); + return (uint8_t) roundf(clamp(this->target_temperature, GREE_TEMP_MIN, GREE_TEMP_MAX)) - GREE_TEMP_MIN; } uint8_t GreeClimate::preset_() { From 499ce56c2bd41f6f86d3a482b99953eab837bab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:49:47 +0200 Subject: [PATCH 573/759] [mk2pvrouter] Add binary_sensor platform (#19719) Co-authored-by: Claude Opus 5.5 (1M context) --- .../mk2pvrouter/binary_sensor/__init__.py | 27 +++++++++++++++++++ .../mk2pvrouter_binary_sensor.cpp | 21 +++++++++++++++ .../binary_sensor/mk2pvrouter_binary_sensor.h | 15 +++++++++++ .../mk2pvrouter_binary_sensor_test.cpp | 21 +++++++++++++++ tests/components/mk2pvrouter/common.yaml | 7 +++++ 5 files changed, 91 insertions(+) create mode 100644 esphome/components/mk2pvrouter/binary_sensor/__init__.py create mode 100644 esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp create mode 100644 esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h create mode 100644 tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp diff --git a/esphome/components/mk2pvrouter/binary_sensor/__init__.py b/esphome/components/mk2pvrouter/binary_sensor/__init__.py new file mode 100644 index 00000000000..527c6a1eb21 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterBinarySensor = mk2pvrouter_ns.class_( + "Mk2PVRouterBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(Mk2PVRouterBinarySensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await binary_sensor.register_binary_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp new file mode 100644 index 00000000000..9201596af28 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.cpp @@ -0,0 +1,21 @@ +#include "mk2pvrouter_binary_sensor.h" +#include "esphome/core/log.h" +#include + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_binary_sensor"; + +Mk2PVRouterBinarySensor::Mk2PVRouterBinarySensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterBinarySensor::publish_val(const char *val) { + bool state = (strcmp(val, "0") != 0); + this->publish_state(state); +} + +void Mk2PVRouterBinarySensor::dump_config() { + LOG_BINARY_SENSOR(" ", "Mk2PVRouter Binary Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h new file mode 100644 index 00000000000..0cdbee1fd92 --- /dev/null +++ b/esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterBinarySensor final : public Mk2PVRouterListener, public binary_sensor::BinarySensor, public Component { + public: + explicit Mk2PVRouterBinarySensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp b/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp new file mode 100644 index 00000000000..4303bc8a3e6 --- /dev/null +++ b/tests/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor_test.cpp @@ -0,0 +1,21 @@ +#include + +#include "esphome/components/mk2pvrouter/binary_sensor/mk2pvrouter_binary_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterBinarySensorTest, ZeroPublishesOff) { + Mk2PVRouterBinarySensor sensor("R1"); + sensor.publish_val("0"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +TEST(Mk2PVRouterBinarySensorTest, OnePublishesOn) { + Mk2PVRouterBinarySensor sensor("R1"); + sensor.publish_val("1"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); +} + +} // namespace esphome::mk2pvrouter::testing diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml index cdd4a70f817..c96f1404b8a 100644 --- a/tests/components/mk2pvrouter/common.yaml +++ b/tests/components/mk2pvrouter/common.yaml @@ -59,3 +59,10 @@ sensor: name: Relay 10 State tag: R10 mk2pvrouter_id: test_mk2pvrouter + +binary_sensor: + # Test relay as binary sensor + - platform: mk2pvrouter + name: Relay 2 Binary + tag: R2 + mk2pvrouter_id: test_mk2pvrouter From 04a53264b427c37ba4f39fd1811d31aa50941e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:37:31 +0200 Subject: [PATCH 574/759] [mk2pvrouter] Add text_sensor platform (#19720) Co-authored-by: Claude Opus 5.5 (1M context) --- .../mk2pvrouter/text_sensor/__init__.py | 27 +++++++++++++++++++ .../text_sensor/mk2pvrouter_text_sensor.cpp | 17 ++++++++++++ .../text_sensor/mk2pvrouter_text_sensor.h | 15 +++++++++++ tests/components/mk2pvrouter/common.yaml | 12 +++++++++ .../mk2pvrouter_text_sensor_test.cpp | 21 +++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 esphome/components/mk2pvrouter/text_sensor/__init__.py create mode 100644 esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp create mode 100644 esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h create mode 100644 tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp diff --git a/esphome/components/mk2pvrouter/text_sensor/__init__.py b/esphome/components/mk2pvrouter/text_sensor/__init__.py new file mode 100644 index 00000000000..11689f8bc7b --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterTextSensor = mk2pvrouter_ns.class_( + "Mk2PVRouterTextSensor", text_sensor.TextSensor, cg.Component +) + +CONFIG_SCHEMA = text_sensor.text_sensor_schema(Mk2PVRouterTextSensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await text_sensor.register_text_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp new file mode 100644 index 00000000000..4aea2ef7fc7 --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.cpp @@ -0,0 +1,17 @@ +#include "mk2pvrouter_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_text_sensor"; + +Mk2PVRouterTextSensor::Mk2PVRouterTextSensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterTextSensor::publish_val(const char *val) { this->publish_state(val); } + +void Mk2PVRouterTextSensor::dump_config() { + LOG_TEXT_SENSOR(" ", "Mk2PVRouter Text Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h new file mode 100644 index 00000000000..590c67005c8 --- /dev/null +++ b/esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterTextSensor final : public Mk2PVRouterListener, public text_sensor::TextSensor, public Component { + public: + explicit Mk2PVRouterTextSensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml index c96f1404b8a..c3efb85196b 100644 --- a/tests/components/mk2pvrouter/common.yaml +++ b/tests/components/mk2pvrouter/common.yaml @@ -66,3 +66,15 @@ binary_sensor: name: Relay 2 Binary tag: R2 mk2pvrouter_id: test_mk2pvrouter + +text_sensor: + - platform: mk2pvrouter + name: S per MC + tag: S_MC + mk2pvrouter_id: test_mk2pvrouter + + # Test another text tag + - platform: mk2pvrouter + name: Status + tag: STATUS + mk2pvrouter_id: test_mk2pvrouter diff --git a/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp b/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp new file mode 100644 index 00000000000..666797906b9 --- /dev/null +++ b/tests/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor_test.cpp @@ -0,0 +1,21 @@ +#include + +#include "esphome/components/mk2pvrouter/text_sensor/mk2pvrouter_text_sensor.h" + +namespace esphome::mk2pvrouter::testing { + +TEST(Mk2PVRouterTextSensorTest, PublishesValueVerbatim) { + Mk2PVRouterTextSensor sensor("S_MC"); + sensor.publish_val("-1234"); + ASSERT_TRUE(sensor.has_state()); + EXPECT_EQ(sensor.get_state(), "-1234"); +} + +TEST(Mk2PVRouterTextSensorTest, ShorterValueReplacesPreviousOne) { + Mk2PVRouterTextSensor sensor("S_MC"); + sensor.publish_val("-1234"); + sensor.publish_val("5"); + EXPECT_EQ(sensor.get_state(), "5"); +} + +} // namespace esphome::mk2pvrouter::testing From cdc77e21269e878cbc27af07bf19002b1dad2dd6 Mon Sep 17 00:00:00 2001 From: Dckiller <53062806+dckiller51@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:11:50 +0200 Subject: [PATCH 575/759] [xiaomi_body_scale] Add Xiaomi body scale S200 and S400 (#16476) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/xiaomi_body_scale/__init__.py | 1 + .../components/xiaomi_body_scale/sensor.py | 93 ++++++++++++ .../xiaomi_body_scale/xiaomi_body_scale.cpp | 141 ++++++++++++++++++ .../xiaomi_body_scale/xiaomi_body_scale.h | 40 +++++ .../xiaomi_body_scale/common-ln.yaml | 16 ++ tests/components/xiaomi_body_scale/common.h | 78 ++++++++++ .../components/xiaomi_body_scale/common.yaml | 27 ++++ .../sensor/xiaomi_body_scale_test.cpp | 112 ++++++++++++++ .../xiaomi_body_scale/test.esp32-idf.yaml | 3 + .../xiaomi_body_scale/test.ln882x-ard.yaml | 3 + .../validate.bk72xx-ard.yaml | 28 ++++ 12 files changed, 543 insertions(+) create mode 100644 esphome/components/xiaomi_body_scale/__init__.py create mode 100644 esphome/components/xiaomi_body_scale/sensor.py create mode 100644 esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp create mode 100644 esphome/components/xiaomi_body_scale/xiaomi_body_scale.h create mode 100644 tests/components/xiaomi_body_scale/common-ln.yaml create mode 100644 tests/components/xiaomi_body_scale/common.h create mode 100644 tests/components/xiaomi_body_scale/common.yaml create mode 100644 tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp create mode 100644 tests/components/xiaomi_body_scale/test.esp32-idf.yaml create mode 100644 tests/components/xiaomi_body_scale/test.ln882x-ard.yaml create mode 100644 tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index cfe5638a616..bc601f05334 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -641,6 +641,7 @@ esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier +esphome/components/xiaomi_body_scale/* @dckiller51 esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 esphome/components/xiaomi_lywsd03mmc/* @ahpohl diff --git a/esphome/components/xiaomi_body_scale/__init__.py b/esphome/components/xiaomi_body_scale/__init__.py new file mode 100644 index 00000000000..3d75ef24ecb --- /dev/null +++ b/esphome/components/xiaomi_body_scale/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@dckiller51"] diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py new file mode 100644 index 00000000000..c8cbc0e820a --- /dev/null +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BINDKEY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_WEIGHT, + DEVICE_CLASS_WEIGHT, + ICON_HEART_PULSE, + ICON_OMEGA, + ICON_SCALE_BATHROOM, + STATE_CLASS_MEASUREMENT, + UNIT_BEATS_PER_MINUTE, + UNIT_KILOGRAM, + UNIT_OHM, +) +from esphome.types import ConfigType + +CONF_IMPEDANCE_LOW = "impedance_low" +CONF_IMPEDANCE_HIGH = "impedance_high" +CONF_HEART_RATE = "heart_rate" +CONF_PROFILE_ID = "profile_id" +CONF_STABILIZED = "stabilized" + +AUTO_LOAD = ["ble_device_base", "binary_sensor"] + +xiaomi_body_scale_ns = cg.esphome_ns.namespace("xiaomi_body_scale") +XiaomiBodyScale = xiaomi_body_scale_ns.class_( + "XiaomiBodyScale", ble_device_base.ESPBTDeviceListener, cg.Component +) + +IMPEDANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XiaomiBodyScale), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_WEIGHT): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOGRAM, + accuracy_decimals=2, + device_class=DEVICE_CLASS_WEIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IMPEDANCE_LOW): IMPEDANCE_SCHEMA, + cv.Optional(CONF_IMPEDANCE_HIGH): IMPEDANCE_SCHEMA, + cv.Optional(CONF_HEART_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_BEATS_PER_MINUTE, + icon=ICON_HEART_PULSE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROFILE_ID): sensor.sensor_schema( + icon="mdi:identifier", + accuracy_decimals=0, + ), + cv.Optional(CONF_STABILIZED): binary_sensor.binary_sensor_schema( + icon=ICON_SCALE_BATHROOM, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + ) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + for key, setter in ( + (CONF_WEIGHT, var.set_weight), + (CONF_IMPEDANCE_LOW, var.set_impedance_low), + (CONF_IMPEDANCE_HIGH, var.set_impedance_high), + (CONF_HEART_RATE, var.set_heart_rate), + (CONF_PROFILE_ID, var.set_profile_id), + ): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(setter(sens)) + if (conf := config.get(CONF_STABILIZED)) is not None: + sens = await binary_sensor.new_binary_sensor(conf) + cg.add(var.set_stabilized(sens)) diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp new file mode 100644 index 00000000000..7f9cd17d1f1 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.cpp @@ -0,0 +1,141 @@ +#include "xiaomi_body_scale.h" +#include "esphome/components/ble_device_base/ble_aes_ccm.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::xiaomi_body_scale { + +static const char *const TAG = "xiaomi_body_scale"; + +// Encrypted MiBeacon frame without a MAC: frame control, device id, frame count, cipher, counter, tag +static constexpr size_t DEVICE_ID_POS = 2; +static constexpr size_t FRAME_COUNT_POS = 4; +static constexpr size_t CIPHER_POS = 5; +static constexpr size_t CIPHER_SIZE = 12; +static constexpr size_t COUNTER_POS = CIPHER_POS + CIPHER_SIZE; +static constexpr size_t TAG_POS = COUNTER_POS + 3; +static constexpr size_t TAG_SIZE = 4; +static constexpr size_t FRAME_SIZE = TAG_POS + TAG_SIZE; +// Frame control: has data (0x40) and encrypted (0x08) +static constexpr uint8_t FRAME_ENCRYPTED_DATA = 0x48; +static constexpr uint8_t OBJECT_SIZE = 9; +static constexpr uint16_t OBJECT_S200_MEASUREMENT = 0x4E16; +static constexpr uint16_t OBJECT_S400_MEASUREMENT = 0x6E16; +static constexpr uint32_t STABILIZED_RESET_ID = 0; +static constexpr uint32_t STABILIZED_RESET_MS = 1000; + +XiaomiBodyScale::XiaomiBodyScale(uint64_t address, const char *bindkey) : address_(address) { + parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); +} + +void XiaomiBodyScale::dump_config() { + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(this->address_, mac); + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Xiaomi Body Scale\n" + " MAC Address: %s", + format_mac_addr_upper(mac, mac_buf)); + LOG_SENSOR(" ", "Weight", this->weight_); + LOG_SENSOR(" ", "Impedance Low (50 kHz)", this->impedance_low_); + LOG_SENSOR(" ", "Impedance High (250 kHz)", this->impedance_high_); + LOG_SENSOR(" ", "Heart Rate", this->heart_rate_); + LOG_SENSOR(" ", "Profile ID", this->profile_id_); + LOG_BINARY_SENSOR(" ", "Stabilized", this->stabilized_); +} + +bool XiaomiBodyScale::decrypt_(const uint8_t *frame, uint8_t *plaintext) const { + uint8_t nonce[MAC_ADDRESS_SIZE + 6]; + for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) + nonce[i] = static_cast(this->address_ >> (i * 8)); // MAC, reversed + memcpy(nonce + MAC_ADDRESS_SIZE, frame + DEVICE_ID_POS, 3); // device id + frame count + memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + COUNTER_POS, 3); + static constexpr uint8_t AUTH_DATA[1] = {0x11}; + return ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce, sizeof(nonce), AUTH_DATA, sizeof(AUTH_DATA), + frame + CIPHER_POS, CIPHER_SIZE, plaintext, frame + TAG_POS, TAG_SIZE); +} + +void XiaomiBodyScale::publish_stabilized_(bool stabilized) { + if (this->stabilized_ == nullptr) + return; + this->stabilized_->publish_state(stabilized); + // Clear it again so the next measurement is seen as a new one + if (stabilized) + this->set_timeout(STABILIZED_RESET_ID, STABILIZED_RESET_MS, [this]() { this->stabilized_->publish_state(false); }); +} + +void XiaomiBodyScale::publish_s400_(uint32_t packed) { + // Weight x10 (11 bits), heart rate - 50 (7 bits), impedance x10 (14 bits). A measurement sends weight, heart + // rate and the 50 kHz impedance (the larger value), then a packet with only the 250 kHz impedance. + const uint16_t weight = packed & 0x7FF; + const uint8_t heart_rate = (packed >> 11) & 0x7F; + const uint16_t impedance = packed >> 18; + ESP_LOGD(TAG, "weight=%u heart_rate=%u impedance=%u", weight, heart_rate, impedance); + + if (weight != 0 && this->weight_ != nullptr) + this->weight_->publish_state(weight / 10.0f); + if (heart_rate > 0 && heart_rate < 127 && this->heart_rate_ != nullptr) + this->heart_rate_->publish_state(heart_rate + 50.0f); + + if (weight == 0 && heart_rate == 0) { + // Impedance only: the final 250 kHz packet (bare feet); all zero: stepped off + if (impedance != 0 && this->impedance_high_ != nullptr) + this->impedance_high_->publish_state(impedance / 10.0f); + this->publish_stabilized_(impedance != 0); + } else if (impedance != 0) { + if (this->impedance_low_ != nullptr) + this->impedance_low_->publish_state(impedance / 10.0f); + this->publish_stabilized_(false); + } else { + // Weight without impedance: measurement complete (with socks) + this->publish_stabilized_(true); + } +} + +bool XiaomiBodyScale::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + for (const auto &service_data : device.get_service_datas()) { + if (service_data.data.size() != FRAME_SIZE || !service_data.uuid.contains(0x95, 0xFE)) + continue; + const uint8_t *frame = service_data.data.data(); + // The bindkey is required, so plaintext frames are never trusted + if ((frame[0] & FRAME_ENCRYPTED_DATA) != FRAME_ENCRYPTED_DATA) + continue; + + if (frame[FRAME_COUNT_POS] == this->last_frame_count_) + continue; + uint8_t object[CIPHER_SIZE]; + if (!this->decrypt_(frame, object)) { + ESP_LOGW(TAG, "Decryption failed, wrong bindkey?"); + continue; + } + // Only an authenticated frame may advance the duplicate filter + this->last_frame_count_ = frame[FRAME_COUNT_POS]; + + // Both objects hold the profile ID, a packed uint32 (LE) and a timestamp (not published) + const uint16_t value_type = encode_uint16(object[1], object[0]); + const uint8_t *data = object + 3; + const uint32_t packed = encode_uint32(data[4], data[3], data[2], data[1]); + const bool sized = object[2] == OBJECT_SIZE; + if (sized && value_type == OBJECT_S400_MEASUREMENT) { + this->publish_s400_(packed); + } else if (sized && value_type == OBJECT_S200_MEASUREMENT) { + // Weight x100 only + if (packed != 0 && this->weight_ != nullptr) + this->weight_->publish_state(packed / 100.0f); + } else { + ESP_LOGVV(TAG, "Unknown object 0x%04X, length %u", value_type, object[2]); + continue; + } + if (this->profile_id_ != nullptr) + this->profile_id_->publish_state(data[0]); + return true; + } + return false; +} + +} // namespace esphome::xiaomi_body_scale diff --git a/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h new file mode 100644 index 00000000000..4113bd6d267 --- /dev/null +++ b/esphome/components/xiaomi_body_scale/xiaomi_body_scale.h @@ -0,0 +1,40 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ble_device_base/ble_device.h" + +namespace esphome::xiaomi_body_scale { + +class XiaomiBodyScale final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + XiaomiBodyScale(uint64_t address, const char *bindkey); + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + void set_weight(sensor::Sensor *weight) { this->weight_ = weight; } + void set_impedance_low(sensor::Sensor *impedance_low) { this->impedance_low_ = impedance_low; } + void set_impedance_high(sensor::Sensor *impedance_high) { this->impedance_high_ = impedance_high; } + void set_heart_rate(sensor::Sensor *heart_rate) { this->heart_rate_ = heart_rate; } + void set_profile_id(sensor::Sensor *profile_id) { this->profile_id_ = profile_id; } + void set_stabilized(binary_sensor::BinarySensor *stabilized) { this->stabilized_ = stabilized; } + + protected: + bool decrypt_(const uint8_t *frame, uint8_t *plaintext) const; + void publish_s400_(uint32_t packed); + void publish_stabilized_(bool stabilized); + + uint64_t address_; + uint8_t bindkey_[16]; + sensor::Sensor *weight_{nullptr}; + sensor::Sensor *impedance_low_{nullptr}; + sensor::Sensor *impedance_high_{nullptr}; + sensor::Sensor *heart_rate_{nullptr}; + sensor::Sensor *profile_id_{nullptr}; + binary_sensor::BinarySensor *stabilized_{nullptr}; + uint16_t last_frame_count_{0x100}; // outside the 8-bit range, so the first frame always passes +}; + +} // namespace esphome::xiaomi_body_scale diff --git a/tests/components/xiaomi_body_scale/common-ln.yaml b/tests/components/xiaomi_body_scale/common-ln.yaml new file mode 100644 index 00000000000..a6492ed0dc5 --- /dev/null +++ b/tests/components/xiaomi_body_scale/common-ln.yaml @@ -0,0 +1,16 @@ +sensor: + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h new file mode 100644 index 00000000000..67f1eaae91a --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.h @@ -0,0 +1,78 @@ +#pragma once + +#include "esphome/components/xiaomi_body_scale/xiaomi_body_scale.h" +#include "esphome/core/application.h" + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +using Frame = std::array; + +// Real captures and keys from the xiaomi-ble test suite (Bluetooth-Devices/xiaomi-ble) +static constexpr uint64_t SCALE_A = 0x8CD0B2F6BEEFULL; +static constexpr const char *KEY_A = "0728974d657a4b60964c1b1677f35f7c"; +// Weight, heart rate and the 50 kHz impedance +static constexpr Frame PACKET_1 = {0x48, 0x59, 0xd5, 0x3b, 0x0a, 0xbc, 0x07, 0x8f, 0xf2, 0x34, 0x8c, 0x84, + 0x41, 0x38, 0xe9, 0x30, 0x22, 0x00, 0x00, 0x00, 0x9e, 0x53, 0x85, 0x99}; +// Only the 250 kHz impedance, the end of a bare feet measurement +static constexpr Frame PACKET_2 = {0x48, 0x59, 0xd5, 0x3b, 0x0b, 0xd6, 0xef, 0x0b, 0x25, 0xdb, 0x72, 0x78, + 0x5e, 0x7e, 0x2f, 0x46, 0xd6, 0x00, 0x00, 0x00, 0xd8, 0x64, 0x2d, 0xf6}; +// PACKET_1 re-encrypted with KEY_A and frame count 0xFF +static constexpr Frame PACKET_1_COUNT_FF = {0x48, 0x59, 0xd5, 0x3b, 0xff, 0x6c, 0x2f, 0xd8, 0xdb, 0xaa, 0x70, 0xc7, + 0xbb, 0x1f, 0x0a, 0x08, 0x9c, 0x00, 0x00, 0x00, 0x31, 0xce, 0xf1, 0x95}; + +static constexpr uint64_t SCALE_B = 0x04AE4767C67CULL; +static constexpr const char *KEY_B = "02d2900363ef629c736a4549677acbee"; +// Weight without impedance, the end of a measurement with socks +static constexpr Frame SOCKS = {0x48, 0x59, 0xd5, 0x3b, 0x71, 0x53, 0x04, 0x38, 0xb5, 0x89, 0x4b, 0x24, + 0x2c, 0x20, 0x99, 0x08, 0xda, 0x00, 0x00, 0x00, 0x47, 0x9e, 0xcd, 0xa3}; +// All metrics zero, stepped off the scale +static constexpr Frame STEP_OFF = {0x48, 0x59, 0xd5, 0x3b, 0x72, 0x03, 0x6c, 0x67, 0x94, 0x35, 0x5a, 0x19, + 0xdb, 0xc8, 0x64, 0xbf, 0xb3, 0x00, 0x00, 0x00, 0xe4, 0x15, 0x1d, 0xc8}; + +// S200: weight only, 62.25 kg for profile 1 +static constexpr uint64_t SCALE_S200 = 0xD07B6F27D729ULL; +static constexpr const char *KEY_S200 = "653b1b10e1cb35e4ac5e60fa45f3bf29"; +static constexpr Frame S200_WEIGHT = {0x48, 0x59, 0x04, 0x4c, 0x01, 0x9a, 0x80, 0xa2, 0x75, 0x93, 0x90, 0x10, + 0xf0, 0xab, 0xc4, 0xfa, 0xdc, 0x06, 0x00, 0x00, 0x3d, 0x29, 0xc0, 0x44}; + +inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) { + // Service data AD structure for UUID 0xFE95 + std::vector adv = {static_cast(frame.size() + 3), 0x16, 0x95, 0xFE}; + adv.insert(adv.end(), frame.begin(), frame.end()); + uint8_t mac[6]; + for (size_t i = 0; i < 6; i++) + mac[i] = static_cast(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); + return device; +} + +struct Harness { + Harness(uint64_t address, const char *key) : scale(address, key) { + // The test main does not construct App as generated code does; the stabilized reset needs its scheduler + static const bool app_constructed = (new (&App) Application(), true); + (void) app_constructed; + App.pre_setup("test_scale", 10, "", 0); + this->scale.set_weight(&this->weight); + this->scale.set_impedance_low(&this->impedance_low); + this->scale.set_impedance_high(&this->impedance_high); + this->scale.set_heart_rate(&this->heart_rate); + this->scale.set_profile_id(&this->profile_id); + this->scale.set_stabilized(&this->stabilized); + } + + ~Harness() { + // Drop the pending stabilized reset (id 0) while the scale is alive; App outlives each test + App.scheduler.cancel_timeout(&this->scale, 0u); + App.scheduler.call(millis()); + } + + XiaomiBodyScale scale; + sensor::Sensor weight, impedance_low, impedance_high, heart_rate, profile_id; + binary_sensor::BinarySensor stabilized; +}; + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/common.yaml b/tests/components/xiaomi_body_scale/common.yaml new file mode 100644 index 00000000000..6eb2bf76dd9 --- /dev/null +++ b/tests/components/xiaomi_body_scale/common.yaml @@ -0,0 +1,27 @@ +esp32_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight" diff --git a/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp new file mode 100644 index 00000000000..36a93710f9a --- /dev/null +++ b/tests/components/xiaomi_body_scale/sensor/xiaomi_body_scale_test.cpp @@ -0,0 +1,112 @@ +#include "../common.h" + +#include + +#include +#include + +namespace esphome::xiaomi_body_scale::testing { + +TEST(XiaomiBodyScale, DecodesWeightHeartRateAndLowImpedance) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); + EXPECT_FLOAT_EQ(h.impedance_low.state, 543.2f); + EXPECT_FLOAT_EQ(h.heart_rate.state, 92.0f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, HighImpedancePacketCompletesTheMeasurement) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + EXPECT_FLOAT_EQ(h.impedance_high.state, 497.6f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.weight.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, StabilizedClearsAfterOneSecond) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_2))); + ASSERT_TRUE(h.stabilized.state); + const uint32_t start = millis(); + while (h.stabilized.state && millis() - start < 2000) { + App.scheduler.call(millis()); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + EXPECT_FALSE(h.stabilized.state); + EXPECT_GE(millis() - start, 900u); +} + +TEST(XiaomiBodyScale, WeightWithoutImpedanceCompletesTheMeasurement) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_TRUE(h.stabilized.state); +} + +TEST(XiaomiBodyScale, SteppingOffClearsStabilized) { + Harness h(SCALE_B, KEY_B); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, SOCKS))); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_B, STEP_OFF))); + EXPECT_FALSE(h.stabilized.state); + EXPECT_FLOAT_EQ(h.weight.state, 74.7f); // a zero weight is not published +} + +TEST(XiaomiBodyScale, DecodesS200Weight) { + Harness h(SCALE_S200, KEY_S200); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_S200, S200_WEIGHT))); + EXPECT_FLOAT_EQ(h.weight.state, 62.25f); + EXPECT_FLOAT_EQ(h.profile_id.state, 1.0f); + // The S200 has no impedance or heart rate, and does not drive stabilized + EXPECT_FALSE(h.impedance_low.has_state()); + EXPECT_FALSE(h.impedance_high.has_state()); + EXPECT_FALSE(h.heart_rate.has_state()); + EXPECT_FALSE(h.stabilized.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresOtherAddresses) { + Harness h(SCALE_A, KEY_A); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_B, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAWrongBindkey) { + Harness h(SCALE_A, KEY_B); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, RejectsAPlaintextFrame) { + Harness h(SCALE_A, KEY_A); + Frame plain = PACKET_1; + plain[0] &= ~0x08; + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, plain))); + EXPECT_FALSE(h.weight.has_state()); +} + +TEST(XiaomiBodyScale, IgnoresARepeatedFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +TEST(XiaomiBodyScale, AcceptsFrameCountFFAsTheFirstFrame) { + Harness h(SCALE_A, KEY_A); + ASSERT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1_COUNT_FF))); + EXPECT_FLOAT_EQ(h.weight.state, 69.9f); +} + +TEST(XiaomiBodyScale, AFailedFrameDoesNotBlockTheRealOne) { + Harness h(SCALE_A, KEY_A); + Frame forged = PACKET_1; + forged[23] ^= 0xFF; // corrupt the tag, same frame count + EXPECT_FALSE(h.scale.parse_device(advert(SCALE_A, forged))); + EXPECT_TRUE(h.scale.parse_device(advert(SCALE_A, PACKET_1))); +} + +} // namespace esphome::xiaomi_body_scale::testing diff --git a/tests/components/xiaomi_body_scale/test.esp32-idf.yaml b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml new file mode 100644 index 00000000000..4ea8a5c3792 --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + xiaomi_body_scale: !include common.yaml diff --git a/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml new file mode 100644 index 00000000000..9a95e14750b --- /dev/null +++ b/tests/components/xiaomi_body_scale/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + xiaomi_body_scale: !include common-ln.yaml diff --git a/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..6f677a951f5 --- /dev/null +++ b/tests/components/xiaomi_body_scale/validate.bk72xx-ard.yaml @@ -0,0 +1,28 @@ +# Config-only: the CI bk72xx board cannot compile the BLE 5.x tracker +bk72xx_ble_tracker: + id: ble_tracker_hub + +sensor: + # Explicit ble_hub_id: pins the neutral binding as a declared key. + - platform: xiaomi_body_scale + ble_hub_id: ble_tracker_hub + mac_address: "AA:BB:CC:DD:EE:FF" + bindkey: "00112233445566778899aabbccddeeff" + weight: + name: "Scale Weight" + impedance_low: + name: "Scale Impedance Low" + impedance_high: + name: "Scale Impedance High" + heart_rate: + name: "Scale Heart Rate" + profile_id: + name: "Scale Profile ID" + stabilized: + name: "Scale Stabilized" + # No ble_hub_id: exercises the generated binding real configs use. + - platform: xiaomi_body_scale + mac_address: "AA:BB:CC:DD:EE:00" + bindkey: "ffeeddccbbaa99887766554433221100" + weight: + name: "Second Scale Weight" From abd95c5154a82500c1f389a2614e979a777a0ef0 Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Sat, 26 Sep 2026 10:43:06 -0700 Subject: [PATCH 576/759] [ld2410][mqtt][packages][security] Mark string-valued secrets cv.sensitive (#19387) --- esphome/components/ld2410/__init__.py | 2 +- esphome/components/mqtt/__init__.py | 4 ++-- esphome/components/packages/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index f157cb50fd1..d3179d9b302 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -87,7 +87,7 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.use_id(LD2410Component), - cv.Required(CONF_PASSWORD): cv.templatable(cv.string_strict), + cv.Required(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string_strict)), } ) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 37694fcfc54..b6badb4ef96 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -243,8 +243,8 @@ CONFIG_SCHEMA = cv.All( cv.Inclusive(CONF_CLIENT_CERTIFICATE, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 ), - cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.All( - cv.string, cv.only_on_esp32 + cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.sensitive( + cv.All(cv.string, cv.only_on_esp32) ), cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32=False): cv.All( cv.boolean, cv.only_on_esp32 diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 171c6753aec..703af8281d2 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -128,7 +128,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All( cv.Required(CONF_URL): cv.url, cv.Optional(CONF_PATH): cv.string, cv.Optional(CONF_USERNAME): cv.string, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string), cv.Exclusive(CONF_FILE, CONF_FILES): validate_yaml_filename, cv.Exclusive(CONF_FILES, CONF_FILES): cv.All( cv.ensure_list( From dea1200c35730e395390b3fe1d65c6247be715d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 20:08:45 +0100 Subject: [PATCH 577/759] [nrf52] Reboot straight into the app on a 2001 baud touch (#19727) --- esphome/components/nrf52/dfu.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 0122ed9b31b..3d9219e96f8 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -14,6 +14,10 @@ static const char *const TAG = "dfu"; static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic +static const uint8_t DFU_MAGIC_SKIP = 0x6d; // Adafruit nRF52 bootloader: start the app, skip DFU +// Host-side baud rates that trigger an action; see DeviceFirmwareUpdate::setup(). +static const uint32_t DFU_TOUCH_BAUD_RATE = 1200; // reboot into the bootloader (DFU) +static const uint32_t RESET_TOUCH_BAUD_RATE = 2001; // plain reboot back into the application void DeviceFirmwareUpdate::setup() { if (this->reset_pin_ != nullptr) { @@ -21,7 +25,13 @@ void DeviceFirmwareUpdate::setup() { } #if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) zephyr::global_cdc_acm->add_on_rate_callback([this](const device *, uint32_t rate) { - if (rate == 1200) { + if (rate == RESET_TOUCH_BAUD_RATE) { + // A plain reboot for host tools (a logs view's Reset device): the USB CDC has no + // reset line, and the bootloader, once entered, has no host command back to the app. + NRF_POWER->GPREGRET = DFU_MAGIC_SKIP; + arch_feed_wdt(); + App.reboot(); + } else if (rate == DFU_TOUCH_BAUD_RATE) { volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; if (this->reset_pin_ != nullptr) { From 93ec8d9d95e9d8c894855a5f70ef0de08d953f47 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 26 Sep 2026 14:24:45 -0500 Subject: [PATCH 578/759] [pn71xx] Fix NCI transport and card emulation bugs; share code between PN7150 and PN7160 (#19589) Co-authored-by: Claude Opus 5.5 (1M context) --- CODEOWNERS | 1 + esphome/components/nfc/nci_message.cpp | 2 + esphome/components/nfc/nfc.cpp | 3 +- esphome/components/pn7150/__init__.py | 236 +--- esphome/components/pn7150/automation.h | 66 - esphome/components/pn7150/pn7150.cpp | 1134 +--------------- esphome/components/pn7150/pn7150.h | 271 +--- .../pn7150/pn7150_mifare_classic.cpp | 326 ----- esphome/components/pn7150_i2c/pn7150_i2c.cpp | 8 +- esphome/components/pn7160/__init__.py | 240 +--- esphome/components/pn7160/automation.h | 66 - esphome/components/pn7160/pn7160.cpp | 1161 +--------------- esphome/components/pn7160/pn7160.h | 269 +--- .../pn7160/pn7160_mifare_ultralight.cpp | 185 --- esphome/components/pn7160_i2c/pn7160_i2c.cpp | 8 +- esphome/components/pn7160_spi/pn7160_spi.cpp | 23 +- esphome/components/pn71xx/__init__.py | 150 +++ esphome/components/pn71xx/pn71xx.cpp | 1184 +++++++++++++++++ esphome/components/pn71xx/pn71xx.h | 306 +++++ .../pn71xx_mifare_classic.cpp} | 26 +- .../pn71xx_mifare_ultralight.cpp} | 32 +- tests/components/pn71xx/pn71xx_test.cpp | 194 +++ 22 files changed, 1963 insertions(+), 3928 deletions(-) delete mode 100644 esphome/components/pn7150/automation.h delete mode 100644 esphome/components/pn7150/pn7150_mifare_classic.cpp delete mode 100644 esphome/components/pn7160/automation.h delete mode 100644 esphome/components/pn7160/pn7160_mifare_ultralight.cpp create mode 100644 esphome/components/pn71xx/__init__.py create mode 100644 esphome/components/pn71xx/pn71xx.cpp create mode 100644 esphome/components/pn71xx/pn71xx.h rename esphome/components/{pn7160/pn7160_mifare_classic.cpp => pn71xx/pn71xx_mifare_classic.cpp} (94%) rename esphome/components/{pn7150/pn7150_mifare_ultralight.cpp => pn71xx/pn71xx_mifare_ultralight.cpp} (86%) create mode 100644 tests/components/pn71xx/pn71xx_test.cpp diff --git a/CODEOWNERS b/CODEOWNERS index bc601f05334..eaf8c795767 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -422,6 +422,7 @@ esphome/components/pn7150_i2c/* @jesserockz @kbx81 esphome/components/pn7160/* @jesserockz @kbx81 esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 +esphome/components/pn71xx/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core diff --git a/esphome/components/nfc/nci_message.cpp b/esphome/components/nfc/nci_message.cpp index 0b60fd0aded..a144b8a86d7 100644 --- a/esphome/components/nfc/nci_message.cpp +++ b/esphome/components/nfc/nci_message.cpp @@ -116,6 +116,8 @@ void NciMessage::set_header(const uint8_t message_type, const uint8_t gid, const void NciMessage::set_message(const uint8_t message_type, const std::vector &payload) { this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = message_type & nfc::NCI_PKT_MT_MASK; + this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = 0; this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); } diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 76a391f1dea..64f3228b3c2 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -61,7 +61,8 @@ bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_len } uint32_t get_mifare_ultralight_buffer_size(uint32_t message_length) { - uint32_t buffer_size = message_length + 2 + 1; + // TLV header (2 bytes, or 4 for messages of 255 bytes or more) plus the terminator TLV + uint32_t buffer_size = message_length + (message_length < 255 ? 2 : 4) + 1; if (buffer_size % MIFARE_ULTRALIGHT_READ_SIZE != 0) buffer_size = ((buffer_size / MIFARE_ULTRALIGHT_READ_SIZE) + 1) * MIFARE_ULTRALIGHT_READ_SIZE; return buffer_size; diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index da5f4824646..14299ee310a 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -1,245 +1,23 @@ -from esphome import automation, pins -from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components import nfc +from esphome.components import pn71xx import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_IRQ_PIN, - CONF_MESSAGE, - CONF_ON_FINISHED_WRITE, - CONF_ON_TAG, - CONF_ON_TAG_REMOVED, - CONF_TRIGGER_ID, -) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType -AUTO_LOAD = ["binary_sensor", "nfc"] +AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] -CONF_EMULATION_MESSAGE = "emulation_message" -CONF_EMULATION_OFF = "emulation_off" -CONF_EMULATION_ON = "emulation_on" -CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" -CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" -CONF_PN7150_ID = "pn7150_id" -CONF_POLLING_OFF = "polling_off" -CONF_POLLING_ON = "polling_on" -CONF_SET_CLEAN_MODE = "set_clean_mode" -CONF_SET_EMULATION_MESSAGE = "set_emulation_message" -CONF_SET_FORMAT_MODE = "set_format_mode" -CONF_SET_READ_MODE = "set_read_mode" -CONF_SET_WRITE_MESSAGE = "set_write_message" -CONF_SET_WRITE_MODE = "set_write_mode" -CONF_TAG_TTL = "tag_ttl" -CONF_VEN_PIN = "ven_pin" - pn7150_ns = cg.esphome_ns.namespace("pn7150") -PN7150 = pn7150_ns.class_("PN7150", nfc.Nfcc, cg.Component) +PN7150 = pn7150_ns.class_("PN7150", pn71xx.PN71xx) -EmulationOffAction = pn7150_ns.class_("EmulationOffAction", automation.Action) -EmulationOnAction = pn7150_ns.class_("EmulationOnAction", automation.Action) -PollingOffAction = pn7150_ns.class_("PollingOffAction", automation.Action) -PollingOnAction = pn7150_ns.class_("PollingOnAction", automation.Action) -SetCleanModeAction = pn7150_ns.class_("SetCleanModeAction", automation.Action) -SetEmulationMessageAction = pn7150_ns.class_( - "SetEmulationMessageAction", automation.Action -) -SetFormatModeAction = pn7150_ns.class_("SetFormatModeAction", automation.Action) -SetReadModeAction = pn7150_ns.class_("SetReadModeAction", automation.Action) -SetWriteMessageAction = pn7150_ns.class_("SetWriteMessageAction", automation.Action) -SetWriteModeAction = pn7150_ns.class_("SetWriteModeAction", automation.Action) - - -PN7150IsWritingCondition = pn7150_ns.class_( - "PN7150IsWritingCondition", automation.Condition -) - - -IsWritingCondition = nfc.nfc_ns.class_("IsWritingCondition", automation.Condition) - - -SIMPLE_ACTION_SCHEMA = maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(PN7150), - } -) - -SET_MESSAGE_ACTION_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7150), - cv.Required(CONF_MESSAGE): cv.templatable(cv.string), - cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, - } -) - -PN7150_SCHEMA = cv.Schema( +PN7150_SCHEMA = pn71xx.PN71XX_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(PN7150), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), - cv.Optional(CONF_ON_TAG): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_EMULATION_MESSAGE): cv.string, - cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, } -).extend(cv.COMPONENT_SCHEMA) - - -@automation.register_action( - "tag.set_emulation_message", - SetEmulationMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_message", - SetWriteMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -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) - cg.add(var.set_message(template_)) - template_ = await cg.templatable( - config[CONF_INCLUDE_ANDROID_APP_RECORD], args, cg.bool_ - ) - cg.add(var.set_include_android_app_record(template_)) - return var - - -automation.register_parented_action( - "tag.emulation_off", - EmulationOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, ) - -automation.register_parented_action( - "tag.emulation_on", - EmulationOnAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.polling_off", - PollingOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.polling_on", - PollingOnAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_clean_mode", - SetCleanModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_format_mode", - SetFormatModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_read_mode", - SetReadModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_write_mode", - SetWriteModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" - ), - automation.CallbackAutomation( - CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" - ), -) +pn71xx.register_is_writing_condition("pn7150.is_writing", PN7150) 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]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) - - if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): - cg.add(var.set_tag_emulation_message(emulation_message_config)) - cg.add(var.set_tag_emulation_on()) - - if CONF_TAG_TTL in config: - cg.add(var.set_tag_ttl(config[CONF_TAG_TTL])) - - for conf in config.get(CONF_ON_TAG, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontag_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - for conf in config.get(CONF_ON_TAG_REMOVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontagremoved_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - - -automation.register_parented_condition( - "pn7150.is_writing", - PN7150IsWritingCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7150), - } - ), -) + await pn71xx.setup_pn71xx(var, config) diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h deleted file mode 100644 index c3f8d3e5d38..00000000000 --- a/esphome/components/pn7150/automation.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/components/pn7150/pn7150.h" - -namespace esphome::pn7150 { - -template class PN7150IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - -template class EmulationOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } -}; - -template class EmulationOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } -}; - -template class PollingOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_off(); } -}; - -template class PollingOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_on(); } -}; - -template class SetCleanModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->clean_mode(); } -}; - -template class SetFormatModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->format_mode(); } -}; - -template class SetReadModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->read_mode(); } -}; - -template class SetEmulationMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_emulation_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_write_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->write_mode(); } -}; - -} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.cpp b/esphome/components/pn7150/pn7150.cpp index 4e679c664a8..25bb7a0c241 100644 --- a/esphome/components/pn7150/pn7150.cpp +++ b/esphome/components/pn7150/pn7150.cpp @@ -1,239 +1,18 @@ -#include "automation.h" #include "pn7150.h" -#include - -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pn7150 { static const char *const TAG = "pn7150"; -void PN7150::setup() { - this->irq_pin_->setup(); - this->ven_pin_->setup(); - - this->nci_fsm_transition_(); // kick off reset & init processes -} - void PN7150::dump_config() { ESP_LOGCONFIG(TAG, "PN7150:"); - LOG_PIN(" IRQ pin: ", this->irq_pin_); - LOG_PIN(" VEN pin: ", this->ven_pin_); + PN71xx::dump_config(); } -void PN7150::loop() { - this->nci_fsm_transition_(); - this->purge_old_tags_(); -} - -void PN7150::set_tag_emulation_message(std::shared_ptr message) { - this->card_emulation_message_ = std::move(message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7150::set_tag_emulation_message(const optional &message, - const optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->card_emulation_message_ = std::move(ndef_message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7150::set_tag_emulation_message(const char *message, const bool include_android_app_record) { - this->set_tag_emulation_message(std::string(message), include_android_app_record); -} - -void PN7150::set_tag_emulation_off() { - if (this->listening_enabled_) { - this->listening_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation disabled"); -} - -void PN7150::set_tag_emulation_on() { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); - return; - } - if (!this->listening_enabled_) { - this->listening_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation enabled"); -} - -void PN7150::set_polling_off() { - if (this->polling_enabled_) { - this->polling_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling disabled"); -} - -void PN7150::set_polling_on() { - if (!this->polling_enabled_) { - this->polling_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling enabled"); -} - -void PN7150::read_mode() { - this->next_task_ = EP_READ; - ESP_LOGD(TAG, "Waiting to read next tag"); -} - -void PN7150::clean_mode() { - this->next_task_ = EP_CLEAN; - ESP_LOGD(TAG, "Waiting to clean next tag"); -} - -void PN7150::format_mode() { - this->next_task_ = EP_FORMAT; - ESP_LOGD(TAG, "Waiting to format next tag"); -} - -void PN7150::write_mode() { - if (this->next_task_message_to_write_ == nullptr) { - ESP_LOGW(TAG, "Message to write must be set before setting write mode"); - return; - } - - this->next_task_ = EP_WRITE; - ESP_LOGD(TAG, "Waiting to write next tag"); -} - -void PN7150::set_tag_write_message(std::shared_ptr message) { - this->next_task_message_to_write_ = std::move(message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -void PN7150::set_tag_write_message(optional message, optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->next_task_message_to_write_ = std::move(ndef_message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -uint8_t PN7150::set_test_mode(const TestMode test_mode, const std::vector &data, - std::vector &result) { - auto test_oid = TEST_PRBS_OID; - - switch (test_mode) { - case TestMode::TEST_PRBS: - // test_oid = TEST_PRBS_OID; - break; - - case TestMode::TEST_ANTENNA: - test_oid = TEST_ANTENNA_OID; - break; - - case TestMode::TEST_GET_REGISTER: - test_oid = TEST_GET_REGISTER_OID; - break; - - case TestMode::TEST_NONE: - default: - ESP_LOGD(TAG, "Exiting test mode"); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_OK; - } - - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::TEST); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); - - ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); - auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); - - if (status != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - result.clear(); - } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header - if (!result.empty()) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); - } - } - return status; -} - -uint8_t PN7150::reset_core_(const bool reset_config, const bool power) { - if (power) { - this->ven_pin_->digital_write(true); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(true); - delay(NFCC_INIT_TIMEOUT); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, - {(uint8_t) reset_config}); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending reset command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return rx.get_simple_status_response(); - } - // verify reset response +uint8_t PN7150::verify_reset(nfc::NciMessage &rx, const bool reset_config) { + // CORE_RESET_RSP payload: status, NCI version (reported as 1.1, see UM10936 4.1), configuration status if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE)) || (!rx.message_length_is(3)) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] != 0x11) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 2] != (uint8_t) reset_config)) { @@ -242,33 +21,23 @@ uint8_t PN7150::reset_core_(const bool reset_config, const bool power) { return nfc::STATUS_FAILED; } - ESP_LOGD(TAG, "Configuration %s, NCI version: %s", + ESP_LOGD(TAG, "Configuration %s, NCI version: 0x%02X", rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 2] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), - rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] == 0x20 ? LOG_STR_LITERAL("2.0") : LOG_STR_LITERAL("1.0")); + rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1]); return nfc::STATUS_OK; } -uint8_t PN7150::init_core_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending initialise command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { +uint8_t PN7150::process_init_response(nfc::NciMessage &rx) { + // NCI 1.0 CORE_INIT_RSP: the manufacturer ID and 4 bytes of manufacturer specific information follow the list of + // supported RF interfaces, whose length is at offset 8 (UM10936, 5.2) + const auto &msg = rx.get_message(); + if (msg.size() < 9u || msg.size() < 20u + msg[8]) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); + ESP_LOGE(TAG, "Initialise response too short: %s", nfc::format_bytes_to(buf, msg)); return nfc::STATUS_FAILED; } - - uint8_t manf_id = rx.get_message()[15 + rx.get_message()[8]]; - uint8_t hw_version = rx.get_message()[16 + rx.get_message()[8]]; - uint8_t rom_code_version = rx.get_message()[17 + rx.get_message()[8]]; - uint8_t flash_major_version = rx.get_message()[18 + rx.get_message()[8]]; - uint8_t flash_minor_version = rx.get_message()[19 + rx.get_message()[8]]; + const uint8_t n = msg[8]; ESP_LOGD(TAG, "PN7150 chip info:\n" @@ -277,886 +46,9 @@ uint8_t PN7150::init_core_() { " ROM code version: 0x%02X\n" " FLASH major version: 0x%02X\n" " FLASH minor version: 0x%02X", - manf_id, hw_version, rom_code_version, flash_major_version, flash_minor_version); + msg[15 + n], msg[16 + n], msg[17 + n], msg[18 + n], msg[19 + n]); return rx.get_simple_status_response(); } -uint8_t PN7150::send_init_config_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error enabling proprietary extensions"); - return nfc::STATUS_FAILED; - } - - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(std::begin(PMU_CFG), std::end(PMU_CFG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending PMU config"); - return nfc::STATUS_FAILED; - } - - return this->send_core_config_(); -} - -uint8_t PN7150::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); - this->core_config_is_solo_ = true; - - if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); - this->core_config_is_solo_ = false; - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error sending core config"); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7150::refresh_core_config_() { - bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); - - if (this->nci_state_ == NCIState::RFST_DISCOVERY) { - if (this->stop_discovery_() != nfc::STATUS_OK) { - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_FAILED; - } - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - - if (this->core_config_is_solo_ != core_config_should_be_solo) { - if (this->send_core_config_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to refresh core config"); - return nfc::STATUS_FAILED; - } - } - this->config_refresh_pending_ = false; - return nfc::STATUS_OK; -} - -uint8_t PN7150::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending discover map poll config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::set_listen_mode_routing_() { - nfc::NciMessage rx; - nfc::NciMessage tx( - nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(std::begin(RF_LISTEN_MODE_ROUTING_CONFIG), std::end(RF_LISTEN_MODE_ROUTING_CONFIG))); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error setting listen mode routing config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); - - if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); - rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; - } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); - rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; - } - - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - switch (rx.get_simple_status_response()) { - // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call - case nfc::STATUS_OK: - case nfc::DISCOVERY_ALREADY_STARTED: - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - case nfc::DISCOVERY_TEAR_DOWN: - return nfc::STATUS_OK; - - default: - ESP_LOGE(TAG, "Error starting discovery"); - return nfc::STATUS_FAILED; - } - } - - return nfc::STATUS_OK; -} - -uint8_t PN7150::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } - -uint8_t PN7150::deactivate_(const uint8_t type, const uint16_t timeout) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); - - auto status = this->transceive_(tx, rx, timeout); - // if (status != nfc::STATUS_OK) { - // ESP_LOGE(TAG, "Error sending deactivate type %u", type); - // return nfc::STATUS_FAILED; - // } - return status; -} - -void PN7150::select_endpoint_() { - if (this->discovered_endpoint_.empty()) { - ESP_LOGW(TAG, "No cached tags to select"); - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - return; - } - std::vector endpoint_data = {this->discovered_endpoint_[0].id, this->discovered_endpoint_[0].protocol, - 0x01}; // that last byte is the interface ID - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - if (!this->discovered_endpoint_[i].trig_called) { - endpoint_data = {this->discovered_endpoint_[i].id, this->discovered_endpoint_[i].protocol, - 0x01}; // that last byte is the interface ID - this->selecting_endpoint_ = i; - break; - } - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error selecting endpoint"); - } else { - this->nci_fsm_set_state_(NCIState::EP_SELECTING); - } -} - -uint8_t PN7150::read_endpoint_data_(nfc::NfcTag &tag) { - uint8_t type = nfc::guess_tag_type(tag.get_uid().size()); - - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - ESP_LOGV(TAG, "Reading Mifare classic"); - return this->read_mifare_classic_tag_(tag); - - case nfc::TAG_TYPE_2: - ESP_LOGV(TAG, "Reading Mifare ultralight"); - return this->read_mifare_ultralight_tag_(tag); - - case nfc::TAG_TYPE_UNKNOWN: - default: - ESP_LOGV(TAG, "Cannot determine tag type"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::clean_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_mifare_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for cleaning"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::format_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_ndef_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for formatting"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7150::write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->write_mifare_classic_tag_(message); - - case nfc::TAG_TYPE_2: - return this->write_mifare_ultralight_tag_(uid, message); - - default: - ESP_LOGE(TAG, "Unsupported tag for writing"); - break; - } - return nfc::STATUS_FAILED; -} - -std::unique_ptr PN7150::build_tag_(const uint8_t mode_tech, const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - uint8_t uid_length = data[2]; - if (!uid_length) { - ESP_LOGE(TAG, "UID length cannot be zero"); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - const auto *tag_type_str = - nfc::guess_tag_type(uid_length) == nfc::TAG_TYPE_MIFARE_CLASSIC ? nfc::MIFARE_CLASSIC : nfc::NFC_FORUM_TYPE_2; - return make_unique(uid, tag_type_str); - } - } - return nullptr; -} - -optional PN7150::find_tag_uid_(const nfc::NfcTagUid &uid) { - if (!this->discovered_endpoint_.empty()) { - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); - bool uid_match = (uid.size() == existing_tag_uid.size()); - - if (uid_match) { - for (size_t i = 0; i < uid.size(); i++) { - uid_match &= (uid[i] == existing_tag_uid[i]); - } - if (uid_match) { - return i; - } - } - } - } - return nullopt; -} - -void PN7150::purge_old_tags_() { - for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { - if (millis() - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { - this->erase_tag_(i - 1); - } - } -} - -void PN7150::erase_tag_(const uint8_t tag_index) { - if (tag_index < this->discovered_endpoint_.size()) { - for (auto *trigger : this->triggers_ontagremoved_) { - trigger->process(this->discovered_endpoint_[tag_index].tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_off(*this->discovered_endpoint_[tag_index].tag); - } - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); - } -} - -void PN7150::nci_fsm_transition_() { - switch (this->nci_state_) { - case NCIState::NFCC_RESET: - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - [[fallthrough]]; - - case NCIState::NFCC_INIT: - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); - } - [[fallthrough]]; - - case NCIState::NFCC_CONFIG: - if (this->send_init_config_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to send initial config"); - this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); - return; - } else { - this->config_refresh_pending_ = false; - this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_DISCOVER_MAP: - if (this->set_discover_map_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set discover map"); - this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: - if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set listen mode routing"); - this->nci_fsm_set_error_state_(NCIState::RFST_IDLE); - return; - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - [[fallthrough]]; - - case NCIState::RFST_IDLE: - if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { - this->stop_discovery_(); - } - - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - - if (!this->listening_enabled_ && !this->polling_enabled_) { - return; - } - - if (this->start_discovery_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to start discovery"); - this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); - } else { - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - } - return; - - case NCIState::RFST_W4_HOST_SELECT: - select_endpoint_(); - [[fallthrough]]; - - // All cases below are waiting for NOTIFICATION messages - case NCIState::RFST_DISCOVERY: - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - [[fallthrough]]; - - case NCIState::RFST_LISTEN_ACTIVE: - case NCIState::RFST_LISTEN_SLEEP: - case NCIState::RFST_POLL_ACTIVE: - case NCIState::EP_SELECTING: - case NCIState::EP_DEACTIVATING: - if (this->irq_pin_->digital_read()) { - this->process_message_(); - } - break; - - case NCIState::TEST: - case NCIState::FAILED: - case NCIState::NONE: - default: - return; - } -} - -void PN7150::nci_fsm_set_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); - this->nci_state_ = new_state; - this->nci_state_error_ = NCIState::NONE; - this->error_count_ = 0; - this->last_nci_state_change_ = millis(); -} - -bool PN7150::nci_fsm_set_error_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); - this->nci_state_error_ = new_state; - if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { - if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || - (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { - ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); - this->mark_failed(); - this->nci_fsm_set_state_(NCIState::FAILED); - } else { - ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - } - } - return this->error_count_ > NFCC_MAX_ERROR_COUNT; -} - -void PN7150::process_message_() { - nfc::NciMessage rx; - if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { - return; // No data - } - - switch (rx.get_message_type()) { - case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: - if (rx.get_gid() == nfc::RF_GID) { - switch (rx.get_oid()) { - case nfc::RF_INTF_ACTIVATED_OID: - ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); - this->process_rf_intf_activated_oid_(rx); - return; - - case nfc::RF_DISCOVER_OID: - ESP_LOGVV(TAG, "RF_DISCOVER_OID"); - this->process_rf_discover_oid_(rx); - return; - - case nfc::RF_DEACTIVATE_OID: - ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); - this->process_rf_deactivate_oid_(rx); - return; - - default: - ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); - } - } else if (rx.get_gid() == nfc::NCI_CORE_GID) { - switch (rx.get_oid()) { - case nfc::NCI_CORE_GENERIC_ERROR_OID: - ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); - switch (rx.get_simple_status_response()) { - case nfc::DISCOVERY_ALREADY_STARTED: - ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); - break; - - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - // Tag removed too soon - ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); - if (this->nci_state_ == NCIState::EP_SELECTING) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - if (!this->discovered_endpoint_.empty()) { - this->erase_tag_(this->selecting_endpoint_); - } - } else { - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - case nfc::DISCOVERY_TEAR_DOWN: - ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); - break; - - default: - ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); - break; - } - break; - - default: - ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); - } - } else { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - break; - - case nfc::NCI_PKT_MT_CTRL_RESPONSE: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), - nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_CTRL_COMMAND: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_DATA: - this->process_data_message_(rx); - break; - - default: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - } -} - -void PN7150::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated - uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); - uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); - uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); - uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); - uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); - - ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", - interface, protocol, mode_tech, max_size); - - if (mode_tech & nfc::MODE_LISTEN_MASK) { - ESP_LOGVV(TAG, "Tag activated in listen mode"); - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); - return; - } - - this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); - auto incoming_tag = - this->build_tag_(mode_tech, std::vector(rx.get_message().begin() + 10, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{discovery_id, protocol, millis(), std::move(incoming_tag), false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; - - switch (this->next_task_) { - case EP_CLEAN: - ESP_LOGD(TAG, " Tag cleaning"); - if (this->clean_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag cleaning incomplete"); - } - ESP_LOGD(TAG, " Tag cleaned!"); - break; - - case EP_FORMAT: - ESP_LOGD(TAG, " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error formatting tag as NDEF"); - } - ESP_LOGD(TAG, " Tag formatted!"); - break; - - case EP_WRITE: - if (this->next_task_message_to_write_ != nullptr) { - ESP_LOGD(TAG, " Tag writing\n" - " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag could not be formatted for writing"); - } else { - ESP_LOGD(TAG, " Writing NDEF data"); - if (this->write_endpoint_(working_endpoint.tag->get_uid(), this->next_task_message_to_write_) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, " Failed to write message to tag"); - } - ESP_LOGD(TAG, " Finished writing NDEF data"); - this->next_task_message_to_write_ = nullptr; - this->on_finished_write_callback_.call(); - } - } - break; - - case EP_READ: - default: - if (!working_endpoint.trig_called) { - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), - nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); - if (this->read_endpoint_data_(*working_endpoint.tag) != nfc::STATUS_OK) { - ESP_LOGW(TAG, " Unable to read NDEF record(s)"); - } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); - ESP_LOGD(TAG, " NDEF record(s):"); - for (const auto &record : records) { - ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); - } - } else { - ESP_LOGW(TAG, " No NDEF records found"); - } - for (auto *trigger : this->triggers_ontag_) { - trigger->process(working_endpoint.tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_on(*working_endpoint.tag); - } - working_endpoint.trig_called = true; - break; - } - } - if (working_endpoint.tag->get_tag_type() == nfc::MIFARE_CLASSIC) { - this->halt_mifare_classic_tag_(); - } - } - if (this->next_task_ != EP_READ) { - this->read_mode(); - } - - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); -} - -void PN7150::process_rf_discover_oid_(nfc::NciMessage &rx) { - auto incoming_tag = this->build_tag_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), - std::vector(rx.get_message().begin() + 7, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag!"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - millis(), std::move(incoming_tag), false}); - ESP_LOGVV(TAG, "Tag saved"); - } - } - - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - ESP_LOGVV(TAG, "Discovered %u endpoints", this->discovered_endpoint_.size()); - } -} - -void PN7150::process_rf_deactivate_oid_(nfc::NciMessage &rx) { - this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; - - switch (rx.get_simple_status_response()) { - case nfc::DEACTIVATION_TYPE_DISCOVERY: - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - break; - - case nfc::DEACTIVATION_TYPE_IDLE: - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - break; - - case nfc::DEACTIVATION_TYPE_SLEEP: - case nfc::DEACTIVATION_TYPE_SLEEP_AF: - if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); - } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - default: - break; - } -} - -void PN7150::process_data_message_(nfc::NciMessage &rx) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - - std::vector ndef_response; - this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); - - uint16_t ndef_response_size = ndef_response.size(); - if (!ndef_response_size) { - return; // no message returned, we cannot respond - } - - std::vector tx_msg = {nfc::NCI_PKT_MT_DATA, uint8_t((ndef_response_size & 0xFF00) >> 8), - uint8_t(ndef_response_size & 0x00FF)}; - tx_msg.insert(tx_msg.end(), ndef_response.begin(), ndef_response.end()); - nfc::NciMessage tx(tx_msg); - ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending reply for card emulation failed"); - } -} - -void PN7150::card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response) { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); - ndef_response.clear(); - return; - } - - if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_APP_SELECT))) { - // CARD_EMU_T4T_APP_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_CC_SELECT))) { - // CARD_EMU_T4T_CC_SELECT - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_NDEF_SELECT))) { - // CARD_EMU_T4T_NDEF_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_READ), - std::begin(CARD_EMU_T4T_READ))) { - // CARD_EMU_T4T_READ - if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - if (length <= (sizeof(CARD_EMU_T4T_CC) + offset + 2)) { - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); - auto ndef_message = this->card_emulation_message_->encode(); - uint16_t ndef_msg_size = ndef_message.size(); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Encoded NDEF message: %s", nfc::format_bytes_to(ndef_buf, ndef_message)); - - if (length <= (ndef_msg_size + offset + 2)) { - if (offset == 0) { - ndef_response.resize(2); - ndef_response[0] = (ndef_msg_size & 0xFF00) >> 8; - ndef_response[1] = (ndef_msg_size & 0x00FF); - if (length > 2) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 2); - } - } else if (offset == 1) { - ndef_response.resize(1); - ndef_response[0] = (ndef_msg_size & 0x00FF); - if (length > 1) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 1); - } - } else { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length); - } - - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - - if ((offset + length) >= (ndef_msg_size + 2)) { - ESP_LOGD(TAG, "NDEF message sent"); - this->on_emulated_tag_scan_callback_.call(); - } - } - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_WRITE), - std::begin(CARD_EMU_T4T_WRITE))) { - // CARD_EMU_T4T_WRITE - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - std::vector ndef_msg_written; - - ndef_msg_written.insert(ndef_msg_written.end(), response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5 + length); - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(ndef_buf, ndef_msg_written)); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } -} - -uint8_t PN7150::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, - const bool expect_notification) { - uint8_t retries = NFCC_MAX_COMM_FAILS; - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - while (retries) { - // first, send the message we need to send - if (this->write_nfcc(tx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); - // next, the NFCC should send back a response - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error receiving message"); - if (!retries--) { - ESP_LOGE(TAG, " ...giving up"); - return nfc::STATUS_FAILED; - } - } else { - break; - } - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - // validate the response based on the message type that was sent (command vs. data) - if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { - // for commands, the GID and OID should match and the status should be OK - if ((rx.get_gid() != tx.get_gid()) || (rx.get_oid()) != tx.get_oid()) { - ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - return rx.get_simple_status_response(); - } else { - // when requesting data from the endpoint, the first response is from the NFCC; we must validate this, first - if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || - (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { - ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (expect_notification) { - // if the NFCC said "OK", there will be additional data to read; this comes back in a notification message - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error receiving data from endpoint"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - - return nfc::STATUS_OK; - } -} - -uint8_t PN7150::wait_for_irq_(uint16_t timeout, bool pin_state) { - auto start_time = millis(); - - while (millis() - start_time < timeout) { - if (this->irq_pin_->digital_read() == pin_state) { - return nfc::STATUS_OK; - } - } - ESP_LOGW(TAG, "Timed out waiting for IRQ state"); - return nfc::STATUS_FAILED; -} - } // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150.h b/esphome/components/pn7150/pn7150.h index fa38c5c3134..7740954050b 100644 --- a/esphome/components/pn7150/pn7150.h +++ b/esphome/components/pn7150/pn7150.h @@ -1,59 +1,9 @@ #pragma once -#include "esphome/components/nfc/automation.h" -#include "esphome/components/nfc/nci_core.h" -#include "esphome/components/nfc/nci_message.h" -#include "esphome/components/nfc/nfc.h" -#include "esphome/components/nfc/nfc_helpers.h" -#include "esphome/core/component.h" -#include "esphome/core/gpio.h" -#include "esphome/core/helpers.h" - -#include +#include "esphome/components/pn71xx/pn71xx.h" namespace esphome::pn7150 { -static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; -static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; -static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 15; - -static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; -static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; - -static constexpr uint8_t XCHG_DATA_OID = 0x10; -static constexpr uint8_t MF_SECTORSEL_OID = 0x32; -static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; -static constexpr uint8_t TEST_PRBS_OID = 0x30; -static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; -static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; - -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; - -static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, - 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF, 0x04, - 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; -static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; -static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; -static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; -static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; - -static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0x01, // TOTAL_DURATION (low)... - 0x00}; // TOTAL_DURATION (high): 1 ms - -static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0xF8, // TOTAL_DURATION (low)... - 0x02}; // TOTAL_DURATION (high): 760 ms - static constexpr uint8_t PMU_CFG[] = { 0x01, // Number of parameters 0xA0, 0x0E, // ext. tag @@ -63,34 +13,6 @@ static constexpr uint8_t PMU_CFG[] = { 0x01, // RFU; must be 0x00 for CFG1 and 0x01 for CFG2 }; -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes - nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, - nfc::INTF_ISODEP, // poll & listen mode - nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_TAGCMD}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - -static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (another message is coming) 1, // number of table entries 0x01, // type = protocol-based @@ -99,196 +21,15 @@ static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (an 0x01, // power state nfc::PROT_ISODEP}; // protocol -enum class CardEmulationState : uint8_t { - CARD_EMU_IDLE, - CARD_EMU_NDEF_APP_SELECTED, - CARD_EMU_CC_SELECTED, - CARD_EMU_NDEF_SELECTED, - CARD_EMU_DESFIRE_PROD, -}; - -enum class NCIState : uint8_t { - NONE = 0x00, - NFCC_RESET, - NFCC_INIT, - NFCC_CONFIG, - NFCC_SET_DISCOVER_MAP, - NFCC_SET_LISTEN_MODE_ROUTING, - RFST_IDLE, - RFST_DISCOVERY, - RFST_W4_ALL_DISCOVERIES, - RFST_W4_HOST_SELECT, - RFST_LISTEN_ACTIVE, - RFST_LISTEN_SLEEP, - RFST_POLL_ACTIVE, - EP_DEACTIVATING, - EP_SELECTING, - TEST = 0xFE, - FAILED = 0xFF, -}; - -enum class TestMode : uint8_t { - TEST_NONE = 0x00, - TEST_PRBS, - TEST_ANTENNA, - TEST_GET_REGISTER, -}; - -struct DiscoveredEndpoint { - uint8_t id; - uint8_t protocol; - uint32_t last_seen; - std::unique_ptr tag; - bool trig_called; -}; - -class PN7150 : public nfc::Nfcc, public Component { +class PN7150 : public pn71xx::PN71xx { public: - void setup() override; void dump_config() override; - void loop() override; - - void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } - void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } - - void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } - void set_tag_emulation_message(std::shared_ptr message); - void set_tag_emulation_message(const optional &message, optional include_android_app_record); - void set_tag_emulation_message(const char *message, bool include_android_app_record = true); - void set_tag_emulation_off(); - void set_tag_emulation_on(); - bool tag_emulation_enabled() { return this->listening_enabled_; } - - void set_polling_off(); - void set_polling_on(); - bool polling_enabled() { return this->polling_enabled_; } - - void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } - void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } - - template void add_on_emulated_tag_scan_callback(F &&callback) { - this->on_emulated_tag_scan_callback_.add(std::forward(callback)); - } - - template void add_on_finished_write_callback(F &&callback) { - this->on_finished_write_callback_.add(std::forward(callback)); - } - - bool is_writing() { return this->next_task_ != EP_READ; }; - - void read_mode(); - void clean_mode(); - void format_mode(); - void write_mode(); - void set_tag_write_message(std::shared_ptr message); - void set_tag_write_message(optional message, optional include_android_app_record); - - uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); protected: - uint8_t reset_core_(bool reset_config, bool power); - uint8_t init_core_(); - uint8_t send_init_config_(); - uint8_t send_core_config_(); - uint8_t refresh_core_config_(); - - uint8_t set_discover_map_(); - - uint8_t set_listen_mode_routing_(); - - uint8_t start_discovery_(); - uint8_t stop_discovery_(); - uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); - - void select_endpoint_(); - - uint8_t read_endpoint_data_(nfc::NfcTag &tag); - uint8_t clean_endpoint_(nfc::NfcTagUid &uid); - uint8_t format_endpoint_(nfc::NfcTagUid &uid); - uint8_t write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message); - - std::unique_ptr build_tag_(uint8_t mode_tech, const std::vector &data); - optional find_tag_uid_(const nfc::NfcTagUid &uid); - void purge_old_tags_(); - void erase_tag_(uint8_t tag_index); - - /// advance controller state as required - void nci_fsm_transition_(); - /// set new controller state - void nci_fsm_set_state_(NCIState new_state); - /// setting controller to this state caused an error; returns true if too many errors/failures - bool nci_fsm_set_error_state_(NCIState new_state); - /// parse & process incoming messages from the NFCC - void process_message_(); - void process_rf_intf_activated_oid_(nfc::NciMessage &rx); - void process_rf_discover_oid_(nfc::NciMessage &rx); - void process_rf_deactivate_oid_(nfc::NciMessage &rx); - void process_data_message_(nfc::NciMessage &rx); - - void card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response); - - uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, - bool expect_notification = true); - virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; - virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; - - uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); - - uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); - uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); - uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); - uint8_t sect_to_auth_(uint8_t block_num); - uint8_t format_mifare_classic_mifare_(); - uint8_t format_mifare_classic_ndef_(); - uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); - uint8_t halt_mifare_classic_tag_(); - - uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); - uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index); - uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); - uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); - uint8_t clean_mifare_ultralight_(); - - enum NfcTask : uint8_t { - EP_READ = 0, - EP_CLEAN, - EP_FORMAT, - EP_WRITE, - } next_task_{EP_READ}; - - bool config_refresh_pending_{false}; - bool core_config_is_solo_{false}; - bool listening_enabled_{false}; - bool polling_enabled_{true}; - - uint8_t error_count_{0}; - uint8_t fail_count_{0}; - uint32_t last_nci_state_change_{0}; - uint8_t selecting_endpoint_{0}; - uint32_t tag_ttl_{250}; - - GPIOPin *irq_pin_{nullptr}; - GPIOPin *ven_pin_{nullptr}; - - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; - - std::vector discovered_endpoint_; - - CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; - NCIState nci_state_{NCIState::NFCC_RESET}; - NCIState nci_state_error_{NCIState::NONE}; - - std::shared_ptr card_emulation_message_; - std::shared_ptr next_task_message_to_write_; - - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override; + uint8_t process_init_response(nfc::NciMessage &rx) override; + std::span pmu_config() const override { return PMU_CFG; } + std::span listen_mode_routing_config() const override { return RF_LISTEN_MODE_ROUTING_CONFIG; } }; } // namespace esphome::pn7150 diff --git a/esphome/components/pn7150/pn7150_mifare_classic.cpp b/esphome/components/pn7150/pn7150_mifare_classic.cpp deleted file mode 100644 index f1832d95f18..00000000000 --- a/esphome/components/pn7150/pn7150_mifare_classic.cpp +++ /dev/null @@ -1,326 +0,0 @@ -#include -#include - -#include "pn7150.h" -#include "esphome/core/log.h" - -namespace esphome::pn7150 { - -static const char *const TAG = "pn7150.mifare_classic"; - -uint8_t PN7150::read_mifare_classic_tag_(nfc::NfcTag &tag) { - uint8_t current_block = 4; - uint8_t message_start_index = 0; - uint32_t message_length = 0; - - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); - return nfc::STATUS_FAILED; - } - std::vector data; - - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { - return nfc::STATUS_FAILED; - } - } else { - ESP_LOGE(TAG, "Failed to read block %u", current_block); - return nfc::STATUS_FAILED; - } - - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; - - while (index < buffer_size) { - if (nfc::mifare_classic_is_first_block(current_block)) { - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Block authentication failed for %u", current_block); - return nfc::STATUS_FAILED; - } - } - std::vector block_data; - if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading block %u", current_block); - return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); - } - - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; - current_block++; - - if (nfc::mifare_classic_is_trailer_block(current_block)) { - current_block++; - } - } - - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { - return nfc::STATUS_FAILED; - } - - tag.set_ndef_message(make_unique(buffer)); - - return nfc::STATUS_OK; -} - -uint8_t PN7150::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Read XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Timeout reading tag data"); - return nfc::STATUS_FAILED; - } - - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(XCHG_DATA_OID)) || - (!rx.message_length_is(18))) { - ESP_LOGE(TAG, "MFC read block failed - block 0x%02x", block_num); - ESP_LOGV(TAG, "Read response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - data.insert(data.begin(), rx.get_message().begin() + 4, rx.get_message().end() - 1); - - ESP_LOGVV(TAG, " Block %u: %s", block_num, nfc::format_bytes_to(buf, data)); - return nfc::STATUS_OK; -} - -uint8_t PN7150::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_num}); - - switch (key_num) { - case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; - break; - - case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; - break; - - default: - break; - } - - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); - } - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "MFC_AUTHENTICATE_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending MFC_AUTHENTICATE_REQ failed"); - return nfc::STATUS_FAILED; - } - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(MFC_AUTHENTICATE_OID)) || - (rx.get_message()[4] != nfc::STATUS_OK)) { - ESP_LOGE(TAG, "MFC authentication failed - block 0x%02x", block_num); - ESP_LOGVV(TAG, "MFC_AUTHENTICATE_RSP: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - ESP_LOGV(TAG, "MFC block %u authentication succeeded", block_num); - return nfc::STATUS_OK; -} - -uint8_t PN7150::sect_to_auth_(const uint8_t block_num) { - const uint8_t first_high_block = nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW * nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; - if (block_num >= first_high_block) { - return ((block_num - first_high_block) / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_HIGH) + - nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; - } - return block_num / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW; -} - -uint8_t PN7150::format_mifare_classic_mifare_() { - static constexpr std::array BLANK_BUFFER = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array TRAILER_BUFFER = { - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x80, 0x69, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - - auto status = nfc::STATUS_OK; - - for (int block = 0; block < 64; block += 4) { - if (this->auth_mifare_classic_block_(block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - continue; - } - if (block != 0) { - if (this->write_mifare_classic_block_(block, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } - if (this->write_mifare_classic_block_(block + 1, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 1); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 2, BLANK_BUFFER.data(), BLANK_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 2); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER.data(), TRAILER_BUFFER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 3); - status = nfc::STATUS_FAILED; - } - } - - return status; -} - -uint8_t PN7150::format_mifare_classic_ndef_() { - static constexpr std::array EMPTY_NDEF_MESSAGE = { - 0x03, 0x03, 0xD0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array BLANK_BLOCK = { - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - static constexpr std::array BLOCK_1_DATA = { - 0x14, 0x01, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1}; - static constexpr std::array BLOCK_2_DATA = { - 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1, 0x03, 0xE1}; - static constexpr std::array BLOCK_3_TRAILER = { - 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x78, 0x77, 0x88, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - static constexpr std::array NDEF_TRAILER = { - 0xD3, 0xF7, 0xD3, 0xF7, 0xD3, 0xF7, 0x7F, 0x07, 0x88, 0x40, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; - - if (this->auth_mifare_classic_block_(0, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to authenticate block 0 for formatting"); - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(1, BLOCK_1_DATA.data(), BLOCK_1_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(2, BLOCK_2_DATA.data(), BLOCK_2_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(3, BLOCK_3_TRAILER.data(), BLOCK_3_TRAILER.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - - ESP_LOGD(TAG, "Sector 0 formatted with NDEF"); - - auto status = nfc::STATUS_OK; - - for (int block = 4; block < 64; block += 4) { - if (this->auth_mifare_classic_block_(block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - if (block == 4) { - if (this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size()) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } else { - if (this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block); - status = nfc::STATUS_FAILED; - } - } - if (this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 1); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write block %u", block + 2); - status = nfc::STATUS_FAILED; - } - if (this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Unable to write trailer block %u", block + 3); - status = nfc::STATUS_FAILED; - } - } - return status; -} - -uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_WRITE, block_num}); - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 1: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending XCHG_DATA_REQ failed"); - return nfc::STATUS_FAILED; - } - // write command part two - tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); - - ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "MFC XCHG_DATA timed out waiting for XCHG_DATA_RSP during block write"); - return nfc::STATUS_FAILED; - } - - if ((!rx.message_type_is(nfc::NCI_PKT_MT_DATA)) || (!rx.simple_status_response_is(XCHG_DATA_OID)) || - (rx.get_message()[4] != nfc::MIFARE_CMD_ACK)) { - ESP_LOGE(TAG, "MFC write block failed - block 0x%02x", block_num); - ESP_LOGV(TAG, "Write response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); - - uint32_t index = 0; - uint8_t current_block = 4; - - while (index < buffer_length) { - if (nfc::mifare_classic_is_first_block(current_block)) { - if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - } - - if (this->write_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != - nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; - current_block++; - - if (nfc::mifare_classic_is_trailer_block(current_block)) { - // Skipping as cannot write to trailer - current_block++; - } - } - return nfc::STATUS_OK; -} - -uint8_t PN7150::halt_mifare_classic_tag_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_HALT, 0}); - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Halt XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending halt XCHG_DATA_REQ failed"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -} // namespace esphome::pn7150 diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index a61bd27c64e..a4e45b8584a 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -24,10 +24,10 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } } - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7150::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); - return nfc::STATUS_FAILED; + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM10936, 3.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); } return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 5ce0dd12861..4cde36e9e73 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -1,257 +1,37 @@ -from esphome import automation, pins -from esphome.automation import maybe_simple_id +from esphome import pins import esphome.codegen as cg -from esphome.components import nfc +from esphome.components import pn71xx import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_IRQ_PIN, - CONF_MESSAGE, - CONF_ON_FINISHED_WRITE, - CONF_ON_TAG, - CONF_ON_TAG_REMOVED, - CONF_TRIGGER_ID, -) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType -AUTO_LOAD = ["binary_sensor", "nfc"] +AUTO_LOAD = ["pn71xx"] CODEOWNERS = ["@kbx81", "@jesserockz"] CONF_DWL_REQ_PIN = "dwl_req_pin" -CONF_EMULATION_MESSAGE = "emulation_message" -CONF_EMULATION_OFF = "emulation_off" -CONF_EMULATION_ON = "emulation_on" -CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" -CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" -CONF_PN7160_ID = "pn7160_id" -CONF_POLLING_OFF = "polling_off" -CONF_POLLING_ON = "polling_on" -CONF_SET_CLEAN_MODE = "set_clean_mode" -CONF_SET_EMULATION_MESSAGE = "set_emulation_message" -CONF_SET_FORMAT_MODE = "set_format_mode" -CONF_SET_READ_MODE = "set_read_mode" -CONF_SET_WRITE_MESSAGE = "set_write_message" -CONF_SET_WRITE_MODE = "set_write_mode" -CONF_TAG_TTL = "tag_ttl" -CONF_VEN_PIN = "ven_pin" CONF_WKUP_REQ_PIN = "wkup_req_pin" pn7160_ns = cg.esphome_ns.namespace("pn7160") -PN7160 = pn7160_ns.class_("PN7160", nfc.Nfcc, cg.Component) +PN7160 = pn7160_ns.class_("PN7160", pn71xx.PN71xx) -EmulationOffAction = pn7160_ns.class_("EmulationOffAction", automation.Action) -EmulationOnAction = pn7160_ns.class_("EmulationOnAction", automation.Action) -PollingOffAction = pn7160_ns.class_("PollingOffAction", automation.Action) -PollingOnAction = pn7160_ns.class_("PollingOnAction", automation.Action) -SetCleanModeAction = pn7160_ns.class_("SetCleanModeAction", automation.Action) -SetEmulationMessageAction = pn7160_ns.class_( - "SetEmulationMessageAction", automation.Action -) -SetFormatModeAction = pn7160_ns.class_("SetFormatModeAction", automation.Action) -SetReadModeAction = pn7160_ns.class_("SetReadModeAction", automation.Action) -SetWriteMessageAction = pn7160_ns.class_("SetWriteMessageAction", automation.Action) -SetWriteModeAction = pn7160_ns.class_("SetWriteModeAction", automation.Action) - - -PN7160IsWritingCondition = pn7160_ns.class_( - "PN7160IsWritingCondition", automation.Condition -) - - -IsWritingCondition = nfc.nfc_ns.class_("IsWritingCondition", automation.Condition) - - -SIMPLE_ACTION_SCHEMA = maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(PN7160), - } -) - -SET_MESSAGE_ACTION_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7160), - cv.Required(CONF_MESSAGE): cv.templatable(cv.string), - cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, - } -) - -PN7160_SCHEMA = cv.Schema( +PN7160_SCHEMA = pn71xx.PN71XX_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(PN7160), - cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), - cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), - cv.Optional(CONF_ON_TAG): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), - cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), - } - ), cv.Optional(CONF_DWL_REQ_PIN): pins.gpio_output_pin_schema, - cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_WKUP_REQ_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_EMULATION_MESSAGE): cv.string, - cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, } -).extend(cv.COMPONENT_SCHEMA) - - -@automation.register_action( - "tag.set_emulation_message", - SetEmulationMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "tag.set_write_message", - SetWriteMessageAction, - SET_MESSAGE_ACTION_SCHEMA, - synchronous=True, -) -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) - cg.add(var.set_message(template_)) - template_ = await cg.templatable( - config[CONF_INCLUDE_ANDROID_APP_RECORD], args, cg.bool_ - ) - cg.add(var.set_include_android_app_record(template_)) - return var - - -automation.register_parented_action( - "tag.emulation_off", - EmulationOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, ) - -automation.register_parented_action( - "tag.emulation_on", - EmulationOnAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.polling_off", - PollingOffAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.polling_on", - PollingOnAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_clean_mode", - SetCleanModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_format_mode", - SetFormatModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_read_mode", - SetReadModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -automation.register_parented_action( - "tag.set_write_mode", - SetWriteModeAction, - SIMPLE_ACTION_SCHEMA, - synchronous=True, -) - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" - ), - automation.CallbackAutomation( - CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" - ), -) +pn71xx.register_is_writing_condition("pn7160.is_writing", PN7160) async def setup_pn7160(var: MockObj, config: ConfigType) -> None: - await cg.register_component(var, config) + await pn71xx.setup_pn71xx(var, config) if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): pin = await cg.gpio_pin_expression(dwl_req_pin_config) cg.add(var.set_dwl_req_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) - - if wakeup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): - pin = await cg.gpio_pin_expression(wakeup_req_pin_config) + if wkup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): + pin = await cg.gpio_pin_expression(wkup_req_pin_config) cg.add(var.set_wkup_req_pin(pin)) - - if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): - cg.add(var.set_tag_emulation_message(emulation_message_config)) - cg.add(var.set_tag_emulation_on()) - - if CONF_TAG_TTL in config: - cg.add(var.set_tag_ttl(config[CONF_TAG_TTL])) - - for conf in config.get(CONF_ON_TAG, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontag_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - for conf in config.get(CONF_ON_TAG_REMOVED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - cg.add(var.register_ontagremoved_trigger(trigger)) - await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf - ) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - - -automation.register_parented_condition( - "pn7160.is_writing", - PN7160IsWritingCondition, - cv.Schema( - { - cv.GenerateID(): cv.use_id(PN7160), - } - ), -) diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h deleted file mode 100644 index 9f03a5a3d63..00000000000 --- a/esphome/components/pn7160/automation.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "esphome/components/pn7160/pn7160.h" - -namespace esphome::pn7160 { - -template class PN7160IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - -template class EmulationOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } -}; - -template class EmulationOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } -}; - -template class PollingOffAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_off(); } -}; - -template class PollingOnAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_polling_on(); } -}; - -template class SetCleanModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->clean_mode(); } -}; - -template class SetFormatModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->format_mode(); } -}; - -template class SetReadModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->read_mode(); } -}; - -template class SetEmulationMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_emulation_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteMessageAction final : public Action, public Parented { - TEMPLATABLE_VALUE(std::string, message) - TEMPLATABLE_VALUE(bool, include_android_app_record) - - void play(const Ts &...x) override { - this->parent_->set_tag_write_message(this->message_.optional_value(x...), - this->include_android_app_record_.optional_value(x...)); - } -}; - -template class SetWriteModeAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->write_mode(); } -}; - -} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index f2cbfa6bcf7..6dcb73bee12 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -1,10 +1,6 @@ -#include - -#include "automation.h" #include "pn7160.h" #include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pn7160 { @@ -12,250 +8,40 @@ namespace esphome::pn7160 { static const char *const TAG = "pn7160"; void PN7160::setup() { - this->irq_pin_->setup(); - this->ven_pin_->setup(); if (this->dwl_req_pin_ != nullptr) { this->dwl_req_pin_->setup(); } if (this->wkup_req_pin_ != nullptr) { this->wkup_req_pin_->setup(); } - - this->nci_fsm_transition_(); // kick off reset & init processes + PN71xx::setup(); } void PN7160::dump_config() { ESP_LOGCONFIG(TAG, "PN7160:"); + PN71xx::dump_config(); if (this->dwl_req_pin_ != nullptr) { LOG_PIN(" DWL_REQ pin: ", this->dwl_req_pin_); } - LOG_PIN(" IRQ pin: ", this->irq_pin_); - LOG_PIN(" VEN pin: ", this->ven_pin_); if (this->wkup_req_pin_ != nullptr) { LOG_PIN(" WKUP_REQ pin: ", this->wkup_req_pin_); } } -void PN7160::loop() { - this->nci_fsm_transition_(); - this->purge_old_tags_(); -} - -void PN7160::set_tag_emulation_message(std::shared_ptr message) { - this->card_emulation_message_ = std::move(message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7160::set_tag_emulation_message(const optional &message, - const optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->card_emulation_message_ = std::move(ndef_message); - ESP_LOGD(TAG, "Tag emulation message set"); -} - -void PN7160::set_tag_emulation_message(const char *message, const bool include_android_app_record) { - this->set_tag_emulation_message(std::string(message), include_android_app_record); -} - -void PN7160::set_tag_emulation_off() { - if (this->listening_enabled_) { - this->listening_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation disabled"); -} - -void PN7160::set_tag_emulation_on() { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); - return; - } - if (!this->listening_enabled_) { - this->listening_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag emulation enabled"); -} - -void PN7160::set_polling_off() { - if (this->polling_enabled_) { - this->polling_enabled_ = false; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling disabled"); -} - -void PN7160::set_polling_on() { - if (!this->polling_enabled_) { - this->polling_enabled_ = true; - this->config_refresh_pending_ = true; - } - ESP_LOGD(TAG, "Tag polling enabled"); -} - -void PN7160::read_mode() { - this->next_task_ = EP_READ; - ESP_LOGD(TAG, "Waiting to read next tag"); -} - -void PN7160::clean_mode() { - this->next_task_ = EP_CLEAN; - ESP_LOGD(TAG, "Waiting to clean next tag"); -} - -void PN7160::format_mode() { - this->next_task_ = EP_FORMAT; - ESP_LOGD(TAG, "Waiting to format next tag"); -} - -void PN7160::write_mode() { - if (this->next_task_message_to_write_ == nullptr) { - ESP_LOGW(TAG, "Message to write must be set before setting write mode"); - return; - } - - this->next_task_ = EP_WRITE; - ESP_LOGD(TAG, "Waiting to write next tag"); -} - -void PN7160::set_tag_write_message(std::shared_ptr message) { - this->next_task_message_to_write_ = std::move(message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -void PN7160::set_tag_write_message(optional message, optional include_android_app_record) { - if (!message.has_value()) { - return; - } - - auto ndef_message = make_unique(); - - ndef_message->add_uri_record(message.value()); - - if (!include_android_app_record.has_value() || include_android_app_record.value()) { - auto ext_record = make_unique(); - ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); - ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); - ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); - ndef_message->add_record(std::move(ext_record)); - } - - this->next_task_message_to_write_ = std::move(ndef_message); - ESP_LOGD(TAG, "Message to write has been set"); -} - -uint8_t PN7160::set_test_mode(const TestMode test_mode, const std::vector &data, - std::vector &result) { - auto test_oid = TEST_PRBS_OID; - - switch (test_mode) { - case TestMode::TEST_PRBS: - // test_oid = TEST_PRBS_OID; - break; - - case TestMode::TEST_ANTENNA: - test_oid = TEST_ANTENNA_OID; - break; - - case TestMode::TEST_GET_REGISTER: - test_oid = TEST_GET_REGISTER_OID; - break; - - case TestMode::TEST_NONE: - default: - ESP_LOGD(TAG, "Exiting test mode"); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_OK; - } - - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - result.clear(); - return nfc::STATUS_FAILED; - } else { - this->nci_fsm_set_state_(NCIState::TEST); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); - - ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); - auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); - - if (status != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - result.clear(); - } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header - if (!result.empty()) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); - } - } - return status; -} - -uint8_t PN7160::reset_core_(const bool reset_config, const bool power) { +void PN7160::prepare_reset() { + // DWL_REQ must be low when VEN rises, or the chip starts in firmware download mode (UM11495) if (this->dwl_req_pin_ != nullptr) { this->dwl_req_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); + delay(pn71xx::NFCC_RESET_DELAY); } +} - if (power) { - this->ven_pin_->digital_write(true); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(false); - delay(NFCC_DEFAULT_TIMEOUT); - this->ven_pin_->digital_write(true); - delay(NFCC_INIT_TIMEOUT); - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, - {(uint8_t) reset_config}); - - if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending reset command"); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return rx.get_simple_status_response(); - } - // read reset notification - if (this->read_nfcc(rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { +uint8_t PN7160::verify_reset(nfc::NciMessage &rx, const bool reset_config) { + // PN7160 always sends CORE_RESET_NTF after CORE_RESET_RSP (UM11495, 8.2) + if (this->read_nfcc(rx, pn71xx::NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Reset notification was not received"); return nfc::STATUS_FAILED; } - // verify reset notification if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.message_length_is(9)) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET] != 0x02) || (rx.get_message()[nfc::NCI_PKT_PAYLOAD_OFFSET + 1] != (uint8_t) reset_config)) { @@ -264,925 +50,30 @@ uint8_t PN7160::reset_core_(const bool reset_config, const bool power) { return nfc::STATUS_FAILED; } - ESP_LOGD(TAG, "Configuration %s, NCI version: %s, Manufacturer ID: 0x%02X", - rx.get_message()[4] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), - rx.get_message()[5] == 0x20 ? LOG_STR_LITERAL("2.0") : LOG_STR_LITERAL("1.0"), rx.get_message()[6]); - rx.get_message().erase(rx.get_message().begin(), rx.get_message().begin() + 8); - char mfr_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Manufacturer info: %s", nfc::format_bytes_to(mfr_buf, rx.get_message())); + // payload: trigger, config status, NCI version, manufacturer ID, info length (4), then the manufacturer specific + // information: hardware, ROM code, FLASH major and FLASH minor versions (UM11495, 8.2 and 8.3) + const auto &ntf = rx.get_message(); + ESP_LOGD(TAG, + "Configuration %s, NCI version: 0x%02X, Manufacturer ID: 0x%02X\n" + " Hardware version: %u\n" + " ROM code version: %u\n" + " FLASH major version: %u\n" + " FLASH minor version: %u", + ntf[4] ? LOG_STR_LITERAL("reset") : LOG_STR_LITERAL("retained"), ntf[5], ntf[6], ntf[8], ntf[9], ntf[10], + ntf[11]); return nfc::STATUS_OK; } -uint8_t PN7160::init_core_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending initialise command"); - return nfc::STATUS_FAILED; +uint8_t PN7160::process_init_response(nfc::NciMessage &rx) { + // the chip's version information is logged from CORE_RESET_NTF in verify_reset() + if (rx.get_message().size() >= 8) { + std::vector features(rx.get_message().begin() + 4, rx.get_message().begin() + 8); + char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, features)); } - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - uint8_t hw_version = rx.get_message()[17 + rx.get_message()[8]]; - uint8_t rom_code_version = rx.get_message()[18 + rx.get_message()[8]]; - uint8_t flash_major_version = rx.get_message()[19 + rx.get_message()[8]]; - uint8_t flash_minor_version = rx.get_message()[20 + rx.get_message()[8]]; - std::vector features(rx.get_message().begin() + 4, rx.get_message().begin() + 8); - - char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, - "PN7160 chip info:\n" - " Hardware version: %u\n" - " ROM code version: %u\n" - " FLASH major version: %u\n" - " FLASH minor version: %u\n" - " Features: %s", - hw_version, rom_code_version, flash_major_version, flash_minor_version, - nfc::format_bytes_to(feat_buf, features)); - return rx.get_simple_status_response(); } -uint8_t PN7160::send_init_config_() { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error enabling proprietary extensions"); - return nfc::STATUS_FAILED; - } - - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(std::begin(PMU_CFG), std::end(PMU_CFG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending PMU config"); - return nfc::STATUS_FAILED; - } - - return this->send_core_config_(); -} - -uint8_t PN7160::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); - this->core_config_is_solo_ = true; - - if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); - this->core_config_is_solo_ = false; - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error sending core config"); - return nfc::STATUS_FAILED; - } - - return nfc::STATUS_OK; -} - -uint8_t PN7160::refresh_core_config_() { - bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); - - if (this->nci_state_ == NCIState::RFST_DISCOVERY) { - if (this->stop_discovery_() != nfc::STATUS_OK) { - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - return nfc::STATUS_FAILED; - } - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - - if (this->core_config_is_solo_ != core_config_should_be_solo) { - if (this->send_core_config_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to refresh core config"); - return nfc::STATUS_FAILED; - } - } - this->config_refresh_pending_ = false; - return nfc::STATUS_OK; -} - -uint8_t PN7160::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending discover map poll config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::set_listen_mode_routing_() { - nfc::NciMessage rx; - nfc::NciMessage tx( - nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(std::begin(RF_LISTEN_MODE_ROUTING_CONFIG), std::end(RF_LISTEN_MODE_ROUTING_CONFIG))); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error setting listen mode routing config"); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); - - if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); - rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; - } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); - rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; - } - - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - switch (rx.get_simple_status_response()) { - // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call - case nfc::STATUS_OK: - case nfc::DISCOVERY_ALREADY_STARTED: - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - case nfc::DISCOVERY_TEAR_DOWN: - return nfc::STATUS_OK; - - default: - ESP_LOGE(TAG, "Error starting discovery"); - return nfc::STATUS_FAILED; - } - } - - return nfc::STATUS_OK; -} - -uint8_t PN7160::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } - -uint8_t PN7160::deactivate_(const uint8_t type, const uint16_t timeout) { - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); - - auto status = this->transceive_(tx, rx, timeout); - // if (status != nfc::STATUS_OK) { - // ESP_LOGE(TAG, "Error sending deactivate type %u", type); - // return nfc::STATUS_FAILED; - // } - return status; -} - -void PN7160::select_endpoint_() { - if (this->discovered_endpoint_.empty()) { - ESP_LOGW(TAG, "No cached tags to select"); - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - return; - } - std::vector endpoint_data = {this->discovered_endpoint_[0].id, this->discovered_endpoint_[0].protocol, - 0x01}; // that last byte is the interface ID - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - if (!this->discovered_endpoint_[i].trig_called) { - endpoint_data = {this->discovered_endpoint_[i].id, this->discovered_endpoint_[i].protocol, - 0x01}; // that last byte is the interface ID - this->selecting_endpoint_ = i; - break; - } - } - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); - - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error selecting endpoint"); - } else { - this->nci_fsm_set_state_(NCIState::EP_SELECTING); - } -} - -uint8_t PN7160::read_endpoint_data_(nfc::NfcTag &tag) { - uint8_t type = nfc::guess_tag_type(tag.get_uid().size()); - - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - ESP_LOGV(TAG, "Reading Mifare classic"); - return this->read_mifare_classic_tag_(tag); - - case nfc::TAG_TYPE_2: - ESP_LOGV(TAG, "Reading Mifare ultralight"); - return this->read_mifare_ultralight_tag_(tag); - - case nfc::TAG_TYPE_UNKNOWN: - default: - ESP_LOGV(TAG, "Cannot determine tag type"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::clean_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_mifare_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for cleaning"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::format_endpoint_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->format_mifare_classic_ndef_(); - - case nfc::TAG_TYPE_2: - return this->clean_mifare_ultralight_(); - - default: - ESP_LOGE(TAG, "Unsupported tag for formatting"); - break; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - switch (type) { - case nfc::TAG_TYPE_MIFARE_CLASSIC: - return this->write_mifare_classic_tag_(message); - - case nfc::TAG_TYPE_2: - return this->write_mifare_ultralight_tag_(uid, message); - - default: - ESP_LOGE(TAG, "Unsupported tag for writing"); - break; - } - return nfc::STATUS_FAILED; -} - -std::unique_ptr PN7160::build_tag_(const uint8_t mode_tech, const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - uint8_t uid_length = data[2]; - if (!uid_length) { - ESP_LOGE(TAG, "UID length cannot be zero"); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - const auto *tag_type_str = - nfc::guess_tag_type(uid_length) == nfc::TAG_TYPE_MIFARE_CLASSIC ? nfc::MIFARE_CLASSIC : nfc::NFC_FORUM_TYPE_2; - return make_unique(uid, tag_type_str); - } - } - return nullptr; -} - -optional PN7160::find_tag_uid_(const nfc::NfcTagUid &uid) { - if (!this->discovered_endpoint_.empty()) { - for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { - auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); - bool uid_match = (uid.size() == existing_tag_uid.size()); - - if (uid_match) { - for (size_t i = 0; i < uid.size(); i++) { - uid_match &= (uid[i] == existing_tag_uid[i]); - } - if (uid_match) { - return i; - } - } - } - } - return nullopt; -} - -void PN7160::purge_old_tags_() { - for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { - if (millis() - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { - this->erase_tag_(i - 1); - } - } -} - -void PN7160::erase_tag_(const uint8_t tag_index) { - if (tag_index < this->discovered_endpoint_.size()) { - for (auto *trigger : this->triggers_ontagremoved_) { - trigger->process(this->discovered_endpoint_[tag_index].tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_off(*this->discovered_endpoint_[tag_index].tag); - } - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); - } -} - -void PN7160::nci_fsm_transition_() { - switch (this->nci_state_) { - case NCIState::NFCC_RESET: - if (this->reset_core_(true, true) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to reset NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_INIT); - } - [[fallthrough]]; - - case NCIState::NFCC_INIT: - if (this->init_core_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to initialise NCI core"); - this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); - } - [[fallthrough]]; - - case NCIState::NFCC_CONFIG: - if (this->send_init_config_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to send initial config"); - this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); - return; - } else { - this->config_refresh_pending_ = false; - this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_DISCOVER_MAP: - if (this->set_discover_map_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set discover map"); - this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - return; - } else { - this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); - } - [[fallthrough]]; - - case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: - if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Failed to set listen mode routing"); - this->nci_fsm_set_error_state_(NCIState::RFST_IDLE); - return; - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - [[fallthrough]]; - - case NCIState::RFST_IDLE: - if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { - this->stop_discovery_(); - } - - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - - if (!this->listening_enabled_ && !this->polling_enabled_) { - return; - } - - if (this->start_discovery_() != nfc::STATUS_OK) { - ESP_LOGV(TAG, "Failed to start discovery"); - this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); - } else { - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - } - return; - - case NCIState::RFST_W4_HOST_SELECT: - select_endpoint_(); - [[fallthrough]]; - - // All cases below are waiting for NOTIFICATION messages - case NCIState::RFST_DISCOVERY: - if (this->config_refresh_pending_) { - this->refresh_core_config_(); - } - [[fallthrough]]; - - case NCIState::RFST_LISTEN_ACTIVE: - case NCIState::RFST_LISTEN_SLEEP: - case NCIState::RFST_POLL_ACTIVE: - case NCIState::EP_SELECTING: - case NCIState::EP_DEACTIVATING: - if (this->irq_pin_->digital_read()) { - this->process_message_(); - } - break; - - case NCIState::FAILED: - case NCIState::NONE: - default: - return; - } -} - -void PN7160::nci_fsm_set_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); - this->nci_state_ = new_state; - this->nci_state_error_ = NCIState::NONE; - this->error_count_ = 0; - this->last_nci_state_change_ = millis(); -} - -bool PN7160::nci_fsm_set_error_state_(NCIState new_state) { - ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); - this->nci_state_error_ = new_state; - if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { - if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || - (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { - ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); - this->mark_failed(); - this->nci_fsm_set_state_(NCIState::FAILED); - } else { - ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); - this->nci_fsm_set_state_(NCIState::NFCC_RESET); - } - } - return this->error_count_ > NFCC_MAX_ERROR_COUNT; -} - -void PN7160::process_message_() { - nfc::NciMessage rx; - if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { - return; // No data - } - - switch (rx.get_message_type()) { - case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: - if (rx.get_gid() == nfc::RF_GID) { - switch (rx.get_oid()) { - case nfc::RF_INTF_ACTIVATED_OID: - ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); - this->process_rf_intf_activated_oid_(rx); - return; - - case nfc::RF_DISCOVER_OID: - ESP_LOGVV(TAG, "RF_DISCOVER_OID"); - this->process_rf_discover_oid_(rx); - return; - - case nfc::RF_DEACTIVATE_OID: - ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); - this->process_rf_deactivate_oid_(rx); - return; - - default: - ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); - } - } else if (rx.get_gid() == nfc::NCI_CORE_GID) { - switch (rx.get_oid()) { - case nfc::NCI_CORE_GENERIC_ERROR_OID: - ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); - switch (rx.get_simple_status_response()) { - case nfc::DISCOVERY_ALREADY_STARTED: - ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); - break; - - case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: - // Tag removed too soon - ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); - if (this->nci_state_ == NCIState::EP_SELECTING) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - if (!this->discovered_endpoint_.empty()) { - this->erase_tag_(this->selecting_endpoint_); - } - } else { - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - case nfc::DISCOVERY_TEAR_DOWN: - ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); - break; - - default: - ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); - break; - } - break; - - default: - ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); - } - } else { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - break; - - case nfc::NCI_PKT_MT_CTRL_RESPONSE: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), - nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_CTRL_COMMAND: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - - case nfc::NCI_PKT_MT_DATA: - this->process_data_message_(rx); - break; - - default: { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); - break; - } - } -} - -void PN7160::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated - uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); - uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); - uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); - uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); - uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); - - ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", - interface, protocol, mode_tech, max_size); - - if (mode_tech & nfc::MODE_LISTEN_MASK) { - ESP_LOGVV(TAG, "Tag activated in listen mode"); - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); - return; - } - - this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); - auto incoming_tag = - this->build_tag_(mode_tech, std::vector(rx.get_message().begin() + 10, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{discovery_id, protocol, millis(), std::move(incoming_tag), false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; - - switch (this->next_task_) { - case EP_CLEAN: - ESP_LOGD(TAG, " Tag cleaning"); - if (this->clean_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag cleaning incomplete"); - } - ESP_LOGD(TAG, " Tag cleaned!"); - break; - - case EP_FORMAT: - ESP_LOGD(TAG, " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error formatting tag as NDEF"); - } - ESP_LOGD(TAG, " Tag formatted!"); - break; - - case EP_WRITE: - if (this->next_task_message_to_write_ != nullptr) { - ESP_LOGD(TAG, " Tag writing\n" - " Tag formatting"); - if (this->format_endpoint_(working_endpoint.tag->get_uid()) != nfc::STATUS_OK) { - ESP_LOGE(TAG, " Tag could not be formatted for writing"); - } else { - ESP_LOGD(TAG, " Writing NDEF data"); - if (this->write_endpoint_(working_endpoint.tag->get_uid(), this->next_task_message_to_write_) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, " Failed to write message to tag"); - } - ESP_LOGD(TAG, " Finished writing NDEF data"); - this->next_task_message_to_write_ = nullptr; - this->on_finished_write_callback_.call(); - } - } - break; - - case EP_READ: - default: - if (!working_endpoint.trig_called) { - char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; - ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), - nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); - if (this->read_endpoint_data_(*working_endpoint.tag) != nfc::STATUS_OK) { - ESP_LOGW(TAG, " Unable to read NDEF record(s)"); - } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); - ESP_LOGD(TAG, " NDEF record(s):"); - for (const auto &record : records) { - ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); - } - } else { - ESP_LOGW(TAG, " No NDEF records found"); - } - for (auto *trigger : this->triggers_ontag_) { - trigger->process(working_endpoint.tag); - } - for (auto *listener : this->tag_listeners_) { - listener->tag_on(*working_endpoint.tag); - } - working_endpoint.trig_called = true; - break; - } - } - if (working_endpoint.tag->get_tag_type() == nfc::MIFARE_CLASSIC) { - this->halt_mifare_classic_tag_(); - } - } - if (this->next_task_ != EP_READ) { - this->read_mode(); - } - - this->stop_discovery_(); - this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); -} - -void PN7160::process_rf_discover_oid_(nfc::NciMessage &rx) { - auto incoming_tag = this->build_tag_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), - std::vector(rx.get_message().begin() + 7, rx.get_message().end())); - - if (incoming_tag == nullptr) { - ESP_LOGE(TAG, "Could not build tag!"); - } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = millis(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - millis(), std::move(incoming_tag), false}); - ESP_LOGVV(TAG, "Tag saved"); - } - } - - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - ESP_LOGVV(TAG, "Discovered %u endpoints", this->discovered_endpoint_.size()); - } -} - -void PN7160::process_rf_deactivate_oid_(nfc::NciMessage &rx) { - this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; - - switch (rx.get_simple_status_response()) { - case nfc::DEACTIVATION_TYPE_DISCOVERY: - this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); - break; - - case nfc::DEACTIVATION_TYPE_IDLE: - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - break; - - case nfc::DEACTIVATION_TYPE_SLEEP: - case nfc::DEACTIVATION_TYPE_SLEEP_AF: - if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); - } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { - this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); - } else { - this->nci_fsm_set_state_(NCIState::RFST_IDLE); - } - break; - - default: - break; - } -} - -void PN7160::process_data_message_(nfc::NciMessage &rx) { - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - - std::vector ndef_response; - this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); - - uint16_t ndef_response_size = ndef_response.size(); - if (!ndef_response_size) { - return; // no message returned, we cannot respond - } - - std::vector tx_msg = {nfc::NCI_PKT_MT_DATA, uint8_t((ndef_response_size & 0xFF00) >> 8), - uint8_t(ndef_response_size & 0x00FF)}; - tx_msg.insert(tx_msg.end(), ndef_response.begin(), ndef_response.end()); - nfc::NciMessage tx(tx_msg); - ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); - if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Sending reply for card emulation failed"); - } -} - -void PN7160::card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response) { - if (this->card_emulation_message_ == nullptr) { - ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); - ndef_response.clear(); - return; - } - - if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_APP_SELECT))) { - // CARD_EMU_T4T_APP_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_CC_SELECT))) { - // CARD_EMU_T4T_CC_SELECT - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, response.end(), std::begin(CARD_EMU_T4T_NDEF_SELECT))) { - // CARD_EMU_T4T_NDEF_SELECT - ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); - this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_READ), - std::begin(CARD_EMU_T4T_READ))) { - // CARD_EMU_T4T_READ - if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - if (length <= (sizeof(CARD_EMU_T4T_CC) + offset + 2)) { - ndef_response.insert(ndef_response.begin(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - // CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED - ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); - auto ndef_message = this->card_emulation_message_->encode(); - uint16_t ndef_msg_size = ndef_message.size(); - uint16_t offset = (response[nfc::NCI_PKT_HEADER_SIZE + 2] << 8) + response[nfc::NCI_PKT_HEADER_SIZE + 3]; - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - - char ndef_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Encoded NDEF message: %s", nfc::format_bytes_to(ndef_buf, ndef_message)); - - if (length <= (ndef_msg_size + offset + 2)) { - if (offset == 0) { - ndef_response.resize(2); - ndef_response[0] = (ndef_msg_size & 0xFF00) >> 8; - ndef_response[1] = (ndef_msg_size & 0x00FF); - if (length > 2) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 2); - } - } else if (offset == 1) { - ndef_response.resize(1); - ndef_response[0] = (ndef_msg_size & 0x00FF); - if (length > 1) { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length - 1); - } - } else { - ndef_response.insert(ndef_response.end(), ndef_message.begin(), ndef_message.begin() + length); - } - - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - - if ((offset + length) >= (ndef_msg_size + 2)) { - ESP_LOGD(TAG, "NDEF message sent"); - this->on_emulated_tag_scan_callback_.call(); - } - } - } - } else if (equal(response.begin() + nfc::NCI_PKT_HEADER_SIZE, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + sizeof(CARD_EMU_T4T_WRITE), - std::begin(CARD_EMU_T4T_WRITE))) { - // CARD_EMU_T4T_WRITE - if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { - ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - uint8_t length = response[nfc::NCI_PKT_HEADER_SIZE + 4]; - std::vector ndef_msg_written; - - ndef_msg_written.insert(ndef_msg_written.end(), response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5, - response.begin() + nfc::NCI_PKT_HEADER_SIZE + 5 + length); - char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(write_buf, ndef_msg_written)); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); - } - } -} - -uint8_t PN7160::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, - const bool expect_notification) { - uint8_t retries = NFCC_MAX_COMM_FAILS; - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - - while (retries) { - // first, send the message we need to send - if (this->write_nfcc(tx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error sending message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); - // next, the NFCC should send back a response - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Error receiving message"); - if (!retries--) { - ESP_LOGE(TAG, " ...giving up"); - return nfc::STATUS_FAILED; - } - } else { - break; - } - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - // validate the response based on the message type that was sent (command vs. data) - if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { - // for commands, the GID and OID should match and the status should be OK - if ((rx.get_gid() != tx.get_gid()) || (rx.get_oid()) != tx.get_oid()) { - ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (!rx.simple_status_response_is(nfc::STATUS_OK)) { - ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - return rx.get_simple_status_response(); - } else { - // when requesting data from the endpoint, the first response is from the NFCC; we must validate this, first - if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || - (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { - ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - return nfc::STATUS_FAILED; - } - - if (expect_notification) { - // if the NFCC said "OK", there will be additional data to read; this comes back in a notification message - if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error receiving data from endpoint"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); - } - - return nfc::STATUS_OK; - } -} - -uint8_t PN7160::wait_for_irq_(uint16_t timeout, bool pin_state) { - auto start_time = millis(); - - while (millis() - start_time < timeout) { - if (this->irq_pin_->digital_read() == pin_state) { - return nfc::STATUS_OK; - } - } - ESP_LOGW(TAG, "Timed out waiting for IRQ state"); - return nfc::STATUS_FAILED; -} - } // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160.h b/esphome/components/pn7160/pn7160.h index da4577874ca..b44a595cced 100644 --- a/esphome/components/pn7160/pn7160.h +++ b/esphome/components/pn7160/pn7160.h @@ -1,59 +1,9 @@ #pragma once -#include "esphome/components/nfc/automation.h" -#include "esphome/components/nfc/nci_core.h" -#include "esphome/components/nfc/nci_message.h" -#include "esphome/components/nfc/nfc.h" -#include "esphome/components/nfc/nfc_helpers.h" -#include "esphome/core/component.h" -#include "esphome/core/gpio.h" -#include "esphome/core/helpers.h" - -#include +#include "esphome/components/pn71xx/pn71xx.h" namespace esphome::pn7160 { -static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 10; -static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; -static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 15; - -static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; -static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; - -static constexpr uint8_t XCHG_DATA_OID = 0x10; -static constexpr uint8_t MF_SECTORSEL_OID = 0x32; -static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; -static constexpr uint8_t TEST_PRBS_OID = 0x30; -static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; -static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; - -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B -static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; - -static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, - 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFF, 0x00, 0xFF, 0x04, - 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; -static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; -static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; -static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; -static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; -static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; - -static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0x01, // TOTAL_DURATION (low)... - 0x00}; // TOTAL_DURATION (high): 1 ms - -static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields - 0x00, // config param identifier (TOTAL_DURATION) - 0x02, // length of value - 0xF8, // TOTAL_DURATION (low)... - 0x02}; // TOTAL_DURATION (high): 760 ms - static constexpr uint8_t PMU_CFG[] = { 0x01, // Number of parameters 0xA0, 0x0E, // ext. tag @@ -73,34 +23,6 @@ static constexpr uint8_t PMU_CFG[] = { 0x0C, // RFU }; -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes - nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_FRAME, // poll mode - nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, - nfc::INTF_ISODEP, // poll & listen mode - nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, - nfc::INTF_TAGCMD}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - -static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode - -static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode - nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode - nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode - static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (another message is coming) 2, // number of table entries 0x01, // type = protocol-based @@ -114,200 +36,23 @@ static constexpr uint8_t RF_LISTEN_MODE_ROUTING_CONFIG[] = {0x00, // "more" (an 0x07, // power state nfc::TECH_PASSIVE_NFCA}; // technology -enum class CardEmulationState : uint8_t { - CARD_EMU_IDLE, - CARD_EMU_NDEF_APP_SELECTED, - CARD_EMU_CC_SELECTED, - CARD_EMU_NDEF_SELECTED, - CARD_EMU_DESFIRE_PROD, -}; - -enum class NCIState : uint8_t { - NONE = 0x00, - NFCC_RESET, - NFCC_INIT, - NFCC_CONFIG, - NFCC_SET_DISCOVER_MAP, - NFCC_SET_LISTEN_MODE_ROUTING, - RFST_IDLE, - RFST_DISCOVERY, - RFST_W4_ALL_DISCOVERIES, - RFST_W4_HOST_SELECT, - RFST_LISTEN_ACTIVE, - RFST_LISTEN_SLEEP, - RFST_POLL_ACTIVE, - EP_DEACTIVATING, - EP_SELECTING, - TEST = 0xFE, - FAILED = 0xFF, -}; - -enum class TestMode : uint8_t { - TEST_NONE = 0x00, - TEST_PRBS, - TEST_ANTENNA, - TEST_GET_REGISTER, -}; - -struct DiscoveredEndpoint { - uint8_t id; - uint8_t protocol; - uint32_t last_seen; - std::unique_ptr tag; - bool trig_called; -}; - -class PN7160 : public nfc::Nfcc, public Component { +class PN7160 : public pn71xx::PN71xx { public: void setup() override; void dump_config() override; - void loop() override; void set_dwl_req_pin(GPIOPin *dwl_req_pin) { this->dwl_req_pin_ = dwl_req_pin; } - void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } - void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } void set_wkup_req_pin(GPIOPin *wkup_req_pin) { this->wkup_req_pin_ = wkup_req_pin; } - void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } - void set_tag_emulation_message(std::shared_ptr message); - void set_tag_emulation_message(const optional &message, optional include_android_app_record); - void set_tag_emulation_message(const char *message, bool include_android_app_record = true); - void set_tag_emulation_off(); - void set_tag_emulation_on(); - bool tag_emulation_enabled() { return this->listening_enabled_; } - - void set_polling_off(); - void set_polling_on(); - bool polling_enabled() { return this->polling_enabled_; } - - void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } - void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } - - template void add_on_emulated_tag_scan_callback(F &&callback) { - this->on_emulated_tag_scan_callback_.add(std::forward(callback)); - } - - template void add_on_finished_write_callback(F &&callback) { - this->on_finished_write_callback_.add(std::forward(callback)); - } - - bool is_writing() { return this->next_task_ != EP_READ; }; - - void read_mode(); - void clean_mode(); - void format_mode(); - void write_mode(); - void set_tag_write_message(std::shared_ptr message); - void set_tag_write_message(optional message, optional include_android_app_record); - - uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); - protected: - uint8_t reset_core_(bool reset_config, bool power); - uint8_t init_core_(); - uint8_t send_init_config_(); - uint8_t send_core_config_(); - uint8_t refresh_core_config_(); - - uint8_t set_discover_map_(); - - uint8_t set_listen_mode_routing_(); - - uint8_t start_discovery_(); - uint8_t stop_discovery_(); - uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); - - void select_endpoint_(); - - uint8_t read_endpoint_data_(nfc::NfcTag &tag); - uint8_t clean_endpoint_(nfc::NfcTagUid &uid); - uint8_t format_endpoint_(nfc::NfcTagUid &uid); - uint8_t write_endpoint_(nfc::NfcTagUid &uid, std::shared_ptr &message); - - std::unique_ptr build_tag_(uint8_t mode_tech, const std::vector &data); - optional find_tag_uid_(const nfc::NfcTagUid &uid); - void purge_old_tags_(); - void erase_tag_(uint8_t tag_index); - - /// advance controller state as required - void nci_fsm_transition_(); - /// set new controller state - void nci_fsm_set_state_(NCIState new_state); - /// setting controller to this state caused an error; returns true if too many errors/failures - bool nci_fsm_set_error_state_(NCIState new_state); - /// parse & process incoming messages from the NFCC - void process_message_(); - void process_rf_intf_activated_oid_(nfc::NciMessage &rx); - void process_rf_discover_oid_(nfc::NciMessage &rx); - void process_rf_deactivate_oid_(nfc::NciMessage &rx); - void process_data_message_(nfc::NciMessage &rx); - - void card_emu_t4t_get_response_(std::vector &response, std::vector &ndef_response); - - uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, - bool expect_notification = true); - virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; - virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; - - uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); - - uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); - uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); - uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); - uint8_t sect_to_auth_(uint8_t block_num); - uint8_t format_mifare_classic_mifare_(); - uint8_t format_mifare_classic_ndef_(); - uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); - uint8_t halt_mifare_classic_tag_(); - - uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); - uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index); - uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); - uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); - uint8_t clean_mifare_ultralight_(); - - enum NfcTask : uint8_t { - EP_READ = 0, - EP_CLEAN, - EP_FORMAT, - EP_WRITE, - } next_task_{EP_READ}; - - bool config_refresh_pending_{false}; - bool core_config_is_solo_{false}; - bool listening_enabled_{false}; - bool polling_enabled_{true}; - - uint8_t error_count_{0}; - uint8_t fail_count_{0}; - uint32_t last_nci_state_change_{0}; - uint8_t selecting_endpoint_{0}; - uint32_t tag_ttl_{250}; + void prepare_reset() override; + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override; + uint8_t process_init_response(nfc::NciMessage &rx) override; + std::span pmu_config() const override { return PMU_CFG; } + std::span listen_mode_routing_config() const override { return RF_LISTEN_MODE_ROUTING_CONFIG; } GPIOPin *dwl_req_pin_{nullptr}; - GPIOPin *irq_pin_{nullptr}; - GPIOPin *ven_pin_{nullptr}; GPIOPin *wkup_req_pin_{nullptr}; - - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; - - std::vector discovered_endpoint_; - - CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; - NCIState nci_state_{NCIState::NFCC_RESET}; - NCIState nci_state_error_{NCIState::NONE}; - - std::shared_ptr card_emulation_message_; - std::shared_ptr next_task_message_to_write_; - - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; }; } // namespace esphome::pn7160 diff --git a/esphome/components/pn7160/pn7160_mifare_ultralight.cpp b/esphome/components/pn7160/pn7160_mifare_ultralight.cpp deleted file mode 100644 index e319a4cb2eb..00000000000 --- a/esphome/components/pn7160/pn7160_mifare_ultralight.cpp +++ /dev/null @@ -1,185 +0,0 @@ -#include -#include -#include - -#include "pn7160.h" -#include "esphome/core/log.h" - -namespace esphome::pn7160 { - -static const char *const TAG = "pn7160.mifare_ultralight"; - -uint8_t PN7160::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; - // pages 3 to 6 contain various info we are interested in -- do one read to grab it all - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, - data) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - - if (!this->is_mifare_ultralight_formatted_(data)) { - ESP_LOGW(TAG, "Not NDEF formatted"); - return nfc::STATUS_FAILED; - } - - uint8_t message_length; - uint8_t message_start_index; - if (this->find_mifare_ultralight_ndef_(data, message_length, message_start_index) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "Couldn't find NDEF message"); - return nfc::STATUS_FAILED; - } - ESP_LOGVV(TAG, "NDEF message length: %u, start: %u", message_length, message_start_index); - - if (message_length == 0) { - return nfc::STATUS_FAILED; - } - // we already read pages 3-6 earlier -- pick up where we left off so we're not re-reading pages - const uint8_t read_length = message_length + message_start_index > 12 ? message_length + message_start_index - 12 : 0; - if (read_length) { - if (read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, read_length, data) != - nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading tag data"); - return nfc::STATUS_FAILED; - } - } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); - - return nfc::STATUS_OK; -} - -uint8_t PN7160::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { - const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); - - for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; - do { // loop because sometimes we struggle here...???... - if (this->transceive_(tx, rx) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error reading tag data"); - return nfc::STATUS_FAILED; - } - } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); - } - } - - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGVV(TAG, "Data read: %s", nfc::format_bytes_to(buf, data)); - - return nfc::STATUS_OK; -} - -bool PN7160::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { - const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector - - return (page_3_to_6.size() > p4_offset + 3) && - ((page_3_to_6[p4_offset + 0] != 0xFF) || (page_3_to_6[p4_offset + 1] != 0xFF) || - (page_3_to_6[p4_offset + 2] != 0xFF) || (page_3_to_6[p4_offset + 3] != 0xFF)); -} - -uint16_t PN7160::read_mifare_ultralight_capacity_() { - std::vector data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { - ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); - return data[2] * 8U; - } - return 0; -} - -uint8_t PN7160::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, - uint8_t &message_start_index) { - const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector - - if (!(page_3_to_6.size() > p4_offset + 6)) { - return nfc::STATUS_FAILED; - } - - if (page_3_to_6[p4_offset + 0] == 0x03) { - message_length = page_3_to_6[p4_offset + 1]; - message_start_index = 2; - return nfc::STATUS_OK; - } else if (page_3_to_6[p4_offset + 5] == 0x03) { - message_length = page_3_to_6[p4_offset + 6]; - message_start_index = 7; - return nfc::STATUS_OK; - } - return nfc::STATUS_FAILED; -} - -uint8_t PN7160::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { - uint32_t capacity = this->read_mifare_ultralight_capacity_(); - - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); - - if (buffer_length > capacity) { - ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); - return nfc::STATUS_FAILED; - } - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); - - uint32_t index = 0; - uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; - - while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != - nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - index += nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - current_page++; - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::clean_mifare_ultralight_() { - uint32_t capacity = this->read_mifare_ultralight_capacity_(); - uint8_t pages = (capacity / nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) + nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; - - static constexpr std::array BLANK_DATA = {0x00, 0x00, 0x00, 0x00}; - - for (int i = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; i < pages; i++) { - if (this->write_mifare_ultralight_page_(i, BLANK_DATA.data(), BLANK_DATA.size()) != nfc::STATUS_OK) { - return nfc::STATUS_FAILED; - } - } - return nfc::STATUS_OK; -} - -uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); - - if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { - ESP_LOGE(TAG, "Error writing page %u", page_num); - return nfc::STATUS_FAILED; - } - return nfc::STATUS_OK; -} - -} // namespace esphome::pn7160 diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index c34cf90e68c..799a258b983 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -24,10 +24,10 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } } - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7160::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); - return nfc::STATUS_FAILED; + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.2.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); } return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index f3c413e9520..43420f9efdf 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -7,7 +7,6 @@ static const char *const TAG = "pn7160_spi"; void PN7160Spi::setup() { this->spi_setup(); - this->cs_->digital_write(false); PN7160::setup(); } @@ -28,20 +27,28 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { this->read_array(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length); } this->disable(); - // semaphore to ensure transaction is complete before returning - if (this->wait_for_irq_(pn7160::NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { - ESP_LOGW(TAG, "read_nfcc_() post-read timeout waiting for IRQ line to clear"); - return nfc::STATUS_FAILED; + // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short + // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.3.4). + if (this->wait_for_irq_(pn71xx::NFCC_IRQ_CLEAR_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGVV(TAG, "IRQ still active after read; another message is pending"); } return nfc::STATUS_OK; } uint8_t PN7160Spi::write_nfcc(nfc::NciMessage &tx) { + auto encoded = tx.encode(); this->enable(); - this->write_byte(TDD_SPI_WRITE); // send "transfer direction detector" - this->write_array(tx.encode().data(), tx.encode().size()); + // send "transfer direction detector"; the NFCC answers 0xFF when it is ready to receive (UM11495, 6.3.3) + const uint8_t status = this->transfer_byte(TDD_SPI_WRITE); + const bool ready = status == 0xFF; + if (ready) { + this->write_array(encoded.data(), encoded.size()); + } this->disable(); - return nfc::STATUS_OK; + if (!ready) { + ESP_LOGV(TAG, "NFCC not ready for write (0x%02X)", status); + } + return ready ? nfc::STATUS_OK : nfc::STATUS_FAILED; } void PN7160Spi::dump_config() { diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py new file mode 100644 index 00000000000..8cda915febc --- /dev/null +++ b/esphome/components/pn71xx/__init__.py @@ -0,0 +1,150 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import nfc +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_IRQ_PIN, + CONF_MESSAGE, + CONF_ON_FINISHED_WRITE, + CONF_ON_TAG, + CONF_ON_TAG_REMOVED, + CONF_TRIGGER_ID, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["binary_sensor", "nfc"] +CODEOWNERS = ["@kbx81", "@jesserockz"] + +CONF_EMULATION_MESSAGE = "emulation_message" +CONF_INCLUDE_ANDROID_APP_RECORD = "include_android_app_record" +CONF_ON_EMULATED_TAG_SCAN = "on_emulated_tag_scan" +CONF_TAG_TTL = "tag_ttl" +CONF_VEN_PIN = "ven_pin" + +pn71xx_ns = cg.esphome_ns.namespace("pn71xx") +PN71xx = pn71xx_ns.class_("PN71xx", nfc.Nfcc, cg.Component) + +SIMPLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PN71xx), + } +) + +SET_MESSAGE_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(PN71xx), + cv.Required(CONF_MESSAGE): cv.templatable(cv.string), + cv.Optional(CONF_INCLUDE_ANDROID_APP_RECORD, default=True): cv.boolean, + } +) + +PN71XX_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_EMULATED_TAG_SCAN): automation.validate_automation({}), + cv.Optional(CONF_ON_FINISHED_WRITE): automation.validate_automation({}), + cv.Optional(CONF_ON_TAG): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), + } + ), + cv.Optional(CONF_ON_TAG_REMOVED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(nfc.NfcOnTagTrigger), + } + ), + cv.Required(CONF_IRQ_PIN): pins.gpio_input_pin_schema, + cv.Required(CONF_VEN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_EMULATION_MESSAGE): cv.string, + cv.Optional(CONF_TAG_TTL): cv.positive_time_period_milliseconds, + } +).extend(cv.COMPONENT_SCHEMA) + + +for _name, _method in ( + ("tag.set_emulation_message", "set_tag_emulation_message"), + ("tag.set_write_message", "set_tag_write_message"), +): + automation.register_apply_action( + _name, + SET_MESSAGE_ACTION_SCHEMA, + automation.ApplyCall( + f"{_method}({{}}, {{}})", + ( + (CONF_MESSAGE, cg.std_string), + (CONF_INCLUDE_ANDROID_APP_RECORD, cg.bool_), + ), + ), + ) + +for _name, _call in ( + ("tag.emulation_off", "set_tag_emulation_off()"), + ("tag.emulation_on", "set_tag_emulation_on()"), + ("tag.polling_off", "set_polling_off()"), + ("tag.polling_on", "set_polling_on()"), + ("tag.set_clean_mode", "clean_mode()"), + ("tag.set_format_mode", "format_mode()"), + ("tag.set_read_mode", "read_mode()"), + ("tag.set_write_mode", "write_mode()"), +): + automation.register_apply_action( + _name, SIMPLE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +def register_is_writing_condition(name: str, chip_class: MockObj) -> None: + """Register the chip-specific ``.is_writing`` condition.""" + automation.register_apply_condition( + name, + cv.Schema( + { + cv.GenerateID(): cv.use_id(chip_class), + } + ), + "is_writing()", + ) + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_EMULATED_TAG_SCAN, "add_on_emulated_tag_scan_callback" + ), + automation.CallbackAutomation( + CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" + ), +) + + +async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) + + pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) + cg.add(var.set_irq_pin(pin)) + + pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) + cg.add(var.set_ven_pin(pin)) + + if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): + cg.add(var.set_tag_emulation_message(emulation_message_config)) + cg.add(var.set_tag_emulation_on()) + + if (tag_ttl := config.get(CONF_TAG_TTL)) is not None: + cg.add(var.set_tag_ttl(tag_ttl)) + + for conf in config.get(CONF_ON_TAG, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.register_ontag_trigger(trigger)) + await automation.build_automation( + trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + ) + + for conf in config.get(CONF_ON_TAG_REMOVED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.register_ontagremoved_trigger(trigger)) + await automation.build_automation( + trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp new file mode 100644 index 00000000000..f13e2de7026 --- /dev/null +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -0,0 +1,1184 @@ +#include +#include + +#include "pn71xx.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::pn71xx { + +static const char *const TAG = "pn71xx"; + +// Builds a message with a URI record and, optionally, the Home Assistant Android app record +static std::unique_ptr build_uri_message(const std::string &uri, + const bool include_android_app_record) { + auto ndef_message = make_unique(); + + ndef_message->add_uri_record(uri); + + if (include_android_app_record) { + auto ext_record = make_unique(); + ext_record->set_tnf(nfc::TNF_EXTERNAL_TYPE); + ext_record->set_type(nfc::HA_TAG_ID_EXT_RECORD_TYPE); + ext_record->set_payload(nfc::HA_TAG_ID_EXT_RECORD_PAYLOAD); + ndef_message->add_record(std::move(ext_record)); + } + return ndef_message; +} + +void PN71xx::setup() { + this->irq_pin_->setup(); + this->ven_pin_->setup(); + + this->nci_fsm_transition_(); // kick off reset & init processes +} + +void PN71xx::dump_config() { + LOG_PIN(" IRQ pin: ", this->irq_pin_); + LOG_PIN(" VEN pin: ", this->ven_pin_); +} + +void PN71xx::loop() { + this->nci_fsm_transition_(); + this->purge_old_tags_(); +} + +void PN71xx::set_tag_emulation_message(const std::shared_ptr &message) { + if (message == nullptr) { + return; + } + // encoded once here so it is validated up front and not re-encoded for every read from the reader + auto encoded = message->encode(); + if (encoded.size() > CARD_EMU_T4T_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "Tag emulation message too long: %zu > %u bytes", encoded.size(), CARD_EMU_T4T_MAX_NDEF_SIZE); + return; + } + this->card_emulation_ndef_ = std::move(encoded); + ESP_LOGD(TAG, "Tag emulation message set"); +} + +void PN71xx::set_tag_emulation_message(const std::string &message, const bool include_android_app_record) { + this->set_tag_emulation_message(build_uri_message(message, include_android_app_record)); +} + +void PN71xx::set_tag_emulation_message(const char *message, const bool include_android_app_record) { + this->set_tag_emulation_message(std::string(message), include_android_app_record); +} + +void PN71xx::set_tag_emulation_off() { + if (this->listening_enabled_) { + this->listening_enabled_ = false; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag emulation disabled"); +} + +void PN71xx::set_tag_emulation_on() { + if (this->card_emulation_ndef_.empty()) { + ESP_LOGE(TAG, "No NDEF message is set; tag emulation cannot be enabled"); + return; + } + if (!this->listening_enabled_) { + this->listening_enabled_ = true; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag emulation enabled"); +} + +void PN71xx::set_polling_off() { + if (this->polling_enabled_) { + this->polling_enabled_ = false; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag polling disabled"); +} + +void PN71xx::set_polling_on() { + if (!this->polling_enabled_) { + this->polling_enabled_ = true; + this->config_refresh_pending_ = true; + } + ESP_LOGD(TAG, "Tag polling enabled"); +} + +void PN71xx::read_mode() { + this->next_task_ = EP_READ; + ESP_LOGD(TAG, "Waiting to read next tag"); +} + +void PN71xx::clean_mode() { + this->next_task_ = EP_CLEAN; + ESP_LOGD(TAG, "Waiting to clean next tag"); +} + +void PN71xx::format_mode() { + this->next_task_ = EP_FORMAT; + ESP_LOGD(TAG, "Waiting to format next tag"); +} + +void PN71xx::write_mode() { + if (this->next_task_message_to_write_ == nullptr) { + ESP_LOGW(TAG, "Message to write must be set before setting write mode"); + return; + } + + this->next_task_ = EP_WRITE; + ESP_LOGD(TAG, "Waiting to write next tag"); +} + +void PN71xx::set_tag_write_message(std::shared_ptr message) { + this->next_task_message_to_write_ = std::move(message); + ESP_LOGD(TAG, "Message to write has been set"); +} + +void PN71xx::set_tag_write_message(const std::string &message, const bool include_android_app_record) { + this->set_tag_write_message(build_uri_message(message, include_android_app_record)); +} + +uint8_t PN71xx::set_test_mode(const TestMode test_mode, const std::vector &data, + std::vector &result) { + auto test_oid = TEST_PRBS_OID; + + switch (test_mode) { + case TestMode::TEST_PRBS: + // test_oid = TEST_PRBS_OID; + break; + + case TestMode::TEST_ANTENNA: + test_oid = TEST_ANTENNA_OID; + break; + + case TestMode::TEST_GET_REGISTER: + test_oid = TEST_GET_REGISTER_OID; + break; + + case TestMode::TEST_NONE: + default: + ESP_LOGD(TAG, "Exiting test mode"); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return nfc::STATUS_OK; + } + + if (this->reset_core_(true, true) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to reset NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); + result.clear(); + return nfc::STATUS_FAILED; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_INIT); + } + if (this->init_core_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to initialise NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); + result.clear(); + return nfc::STATUS_FAILED; + } else { + this->nci_fsm_set_state_(NCIState::TEST); + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, test_oid, data); + + ESP_LOGW(TAG, "Starting test mode, OID 0x%02X", test_oid); + auto status = this->transceive_(tx, rx, NFCC_INIT_TIMEOUT); + + if (status != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to start test mode, OID 0x%02X", test_oid); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + result.clear(); + } else { + result = rx.get_message(); + result.erase(result.begin(), result.begin() + 4); // remove NCI header + if (!result.empty()) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); + } + } + return status; +} + +uint8_t PN71xx::reset_core_(const bool reset_config, const bool power) { + this->prepare_reset(); + + if (power) { + this->ven_pin_->digital_write(true); + delay(NFCC_RESET_DELAY); + this->ven_pin_->digital_write(false); + delay(NFCC_RESET_DELAY); + this->ven_pin_->digital_write(true); + delay(NFCC_INIT_TIMEOUT); + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_RESET_OID, + {(uint8_t) reset_config}); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending reset command"); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGE(TAG, "Invalid reset response: %s", nfc::format_bytes_to(buf, rx.get_message())); + return rx.get_simple_status_response(); + } + return this->verify_reset(rx, reset_config); +} + +uint8_t PN71xx::init_core_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_INIT_OID); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending initialise command"); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGE(TAG, "Invalid initialise response: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + return this->process_init_response(rx); +} + +uint8_t PN71xx::send_init_config_() { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_PROPRIETARY_GID, nfc::NCI_CORE_SET_CONFIG_OID); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error enabling proprietary extensions"); + return nfc::STATUS_FAILED; + } + + const auto pmu_config = this->pmu_config(); + tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, + std::vector(pmu_config.begin(), pmu_config.end())); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending PMU config"); + return nfc::STATUS_FAILED; + } + + return this->send_core_config_(); +} + +uint8_t PN71xx::send_core_config_() { + const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); + const auto *core_config_end = std::end(CORE_CONFIG_SOLO); + this->core_config_is_solo_ = true; + + if (this->listening_enabled_ && this->polling_enabled_) { + core_config_begin = std::begin(CORE_CONFIG_RW_CE); + core_config_end = std::end(CORE_CONFIG_RW_CE); + this->core_config_is_solo_ = false; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, + std::vector(core_config_begin, core_config_end)); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error sending core config"); + return nfc::STATUS_FAILED; + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::refresh_core_config_() { + bool core_config_should_be_solo = !(this->listening_enabled_ && this->polling_enabled_); + + if (this->nci_state_ == NCIState::RFST_DISCOVERY) { + if (this->stop_discovery_() != nfc::STATUS_OK) { + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return nfc::STATUS_FAILED; + } + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + + if (this->core_config_is_solo_ != core_config_should_be_solo) { + if (this->send_core_config_() != nfc::STATUS_OK) { + ESP_LOGV(TAG, "Failed to refresh core config"); + return nfc::STATUS_FAILED; + } + } + this->config_refresh_pending_ = false; + return nfc::STATUS_OK; +} + +uint8_t PN71xx::set_discover_map_() { + std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; + discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error sending discover map poll config"); + return nfc::STATUS_FAILED; + } + return nfc::STATUS_OK; +} + +uint8_t PN71xx::set_listen_mode_routing_() { + const auto routing_config = this->listen_mode_routing_config(); + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, + std::vector(routing_config.begin(), routing_config.end())); + + if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error setting listen mode routing config"); + return nfc::STATUS_FAILED; + } + return nfc::STATUS_OK; +} + +uint8_t PN71xx::start_discovery_() { + const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; + uint8_t length = sizeof(RF_DISCOVERY_CONFIG); + + if (!this->listening_enabled_) { + length = sizeof(RF_DISCOVERY_POLL_CONFIG); + rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; + } else if (!this->polling_enabled_) { + length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); + rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; + } + + std::vector discover_config = std::vector((length * 2) + 1); + + discover_config[0] = length; + for (uint8_t i = 0; i < length; i++) { + discover_config[(i * 2) + 1] = rf_discovery_config[i]; + discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + switch (rx.get_simple_status_response()) { + // in any of these cases, we are either already in or will remain in discovery, which satisfies the function call + case nfc::STATUS_OK: + case nfc::DISCOVERY_ALREADY_STARTED: + case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: + case nfc::DISCOVERY_TEAR_DOWN: + return nfc::STATUS_OK; + + default: + ESP_LOGE(TAG, "Error starting discovery"); + return nfc::STATUS_FAILED; + } + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::stop_discovery_() { return this->deactivate_(nfc::DEACTIVATION_TYPE_IDLE, NFCC_TAG_WRITE_TIMEOUT); } + +uint8_t PN71xx::deactivate_(const uint8_t type, const uint16_t timeout) { + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {type}); + + auto status = this->transceive_(tx, rx, timeout); + // if (status != nfc::STATUS_OK) { + // ESP_LOGE(TAG, "Error sending deactivate type %u", type); + // return nfc::STATUS_FAILED; + // } + return status; +} + +void PN71xx::select_endpoint_() { + if (this->discovered_endpoint_.empty()) { + ESP_LOGW(TAG, "No cached tags to select"); + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + return; + } + this->selecting_endpoint_ = 0; + for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { + if (!this->discovered_endpoint_[i].trig_called) { + this->selecting_endpoint_ = i; + break; + } + } + const auto &endpoint = this->discovered_endpoint_[this->selecting_endpoint_]; + // the RF interface must match the one set for this protocol in RF_DISCOVER_MAP_CONFIG + uint8_t interface = nfc::INTF_FRAME; + if (endpoint.protocol == nfc::PROT_ISODEP) { + interface = nfc::INTF_ISODEP; + } else if (endpoint.protocol == nfc::PROT_MIFARE) { + interface = nfc::INTF_TAGCMD; + } + std::vector endpoint_data = {endpoint.id, endpoint.protocol, interface}; + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); + + if (this->transceive_(tx, rx) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error selecting endpoint"); + } else { + this->nci_fsm_set_state_(NCIState::EP_SELECTING); + } +} + +uint8_t PN71xx::read_endpoint_data_(const uint8_t protocol, nfc::NfcTag &tag) { + switch (protocol) { + case nfc::PROT_MIFARE: + ESP_LOGV(TAG, "Reading Mifare classic"); + return this->read_mifare_classic_tag_(tag); + + case nfc::PROT_T2T: + ESP_LOGV(TAG, "Reading Mifare ultralight"); + return this->read_mifare_ultralight_tag_(tag); + + default: + ESP_LOGV(TAG, "Reading protocol 0x%02X is not supported", protocol); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::clean_endpoint_(const uint8_t protocol) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->format_mifare_classic_mifare_(); + + case nfc::PROT_T2T: + return this->clean_mifare_ultralight_(); + + default: + ESP_LOGE(TAG, "Unsupported tag for cleaning"); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::format_endpoint_(const uint8_t protocol) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->format_mifare_classic_ndef_(); + + case nfc::PROT_T2T: + return this->clean_mifare_ultralight_(); + + default: + ESP_LOGE(TAG, "Unsupported tag for formatting"); + break; + } + return nfc::STATUS_FAILED; +} + +uint8_t PN71xx::write_endpoint_(const uint8_t protocol, nfc::NfcTagUid &uid, + std::shared_ptr &message) { + switch (protocol) { + case nfc::PROT_MIFARE: + return this->write_mifare_classic_tag_(message); + + case nfc::PROT_T2T: + return this->write_mifare_ultralight_tag_(uid, message); + + default: + ESP_LOGE(TAG, "Unsupported tag for writing"); + break; + } + return nfc::STATUS_FAILED; +} + +std::unique_ptr PN71xx::build_tag_(const uint8_t mode_tech, const uint8_t protocol, + const std::vector &data) { + switch (mode_tech) { + case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { + // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... + if (data.size() < 3) { + ESP_LOGE(TAG, "NFC-A parameters too short"); + return nullptr; + } + uint8_t uid_length = data[2]; + if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || data.size() < 3u + uid_length) { + ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); + return nullptr; + } + nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); + if (protocol == nfc::PROT_MIFARE) { + return make_unique(uid, nfc::MIFARE_CLASSIC); + } + if (protocol == nfc::PROT_T2T) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + return make_unique(uid); + } + } + return nullptr; +} + +optional PN71xx::find_tag_uid_(const nfc::NfcTagUid &uid) { + if (!this->discovered_endpoint_.empty()) { + for (size_t i = 0; i < this->discovered_endpoint_.size(); i++) { + auto existing_tag_uid = this->discovered_endpoint_[i].tag->get_uid(); + bool uid_match = (uid.size() == existing_tag_uid.size()); + + if (uid_match) { + for (size_t i = 0; i < uid.size(); i++) { + uid_match &= (uid[i] == existing_tag_uid[i]); + } + if (uid_match) { + return i; + } + } + } + } + return nullopt; +} + +void PN71xx::purge_old_tags_() { + // millis(), not the loop start time: last_seen is stamped after tag operations that may block for seconds + const uint32_t now = millis(); + for (size_t i = this->discovered_endpoint_.size(); i > 0; i--) { + if (now - this->discovered_endpoint_[i - 1].last_seen > this->tag_ttl_) { + this->erase_tag_(i - 1); + } + } +} + +void PN71xx::erase_tag_(const uint8_t tag_index) { + if (tag_index < this->discovered_endpoint_.size()) { + for (auto *trigger : this->triggers_ontagremoved_) { + trigger->process(this->discovered_endpoint_[tag_index].tag); + } + for (auto *listener : this->tag_listeners_) { + listener->tag_off(*this->discovered_endpoint_[tag_index].tag); + } + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); + this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); + } +} + +void PN71xx::nci_fsm_transition_() { + switch (this->nci_state_) { + case NCIState::NFCC_RESET: + if (this->reset_core_(true, true) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to reset NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_RESET); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_INIT); + } + [[fallthrough]]; + + case NCIState::NFCC_INIT: + if (this->init_core_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to initialise NCI core"); + this->nci_fsm_set_error_state_(NCIState::NFCC_INIT); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_CONFIG); + } + [[fallthrough]]; + + case NCIState::NFCC_CONFIG: + if (this->send_init_config_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to send initial config"); + this->nci_fsm_set_error_state_(NCIState::NFCC_CONFIG); + return; + } else { + this->config_refresh_pending_ = false; + this->nci_fsm_set_state_(NCIState::NFCC_SET_DISCOVER_MAP); + } + [[fallthrough]]; + + case NCIState::NFCC_SET_DISCOVER_MAP: + if (this->set_discover_map_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to set discover map"); + this->nci_fsm_set_error_state_(NCIState::NFCC_SET_DISCOVER_MAP); + return; + } else { + this->nci_fsm_set_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); + } + [[fallthrough]]; + + case NCIState::NFCC_SET_LISTEN_MODE_ROUTING: + if (this->set_listen_mode_routing_() != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Failed to set listen mode routing"); + this->nci_fsm_set_error_state_(NCIState::NFCC_SET_LISTEN_MODE_ROUTING); + return; + } else { + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + [[fallthrough]]; + + case NCIState::RFST_IDLE: + if (this->nci_state_error_ == NCIState::RFST_DISCOVERY) { + this->stop_discovery_(); + } + + if (this->config_refresh_pending_) { + this->refresh_core_config_(); + } + + if (!this->listening_enabled_ && !this->polling_enabled_) { + return; + } + + if (this->start_discovery_() != nfc::STATUS_OK) { + ESP_LOGV(TAG, "Failed to start discovery"); + this->nci_fsm_set_error_state_(NCIState::RFST_DISCOVERY); + } else { + this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); + } + return; + + case NCIState::RFST_W4_HOST_SELECT: + select_endpoint_(); + [[fallthrough]]; + + // All cases below are waiting for NOTIFICATION messages + case NCIState::RFST_DISCOVERY: + if (this->config_refresh_pending_) { + this->refresh_core_config_(); + } + [[fallthrough]]; + + case NCIState::RFST_LISTEN_ACTIVE: + case NCIState::RFST_LISTEN_SLEEP: + case NCIState::RFST_POLL_ACTIVE: + case NCIState::EP_SELECTING: + case NCIState::EP_DEACTIVATING: + // only a notification from the NFCC ends the EP_ states; if it was lost, recover rather than wait forever. + // millis(), not the loop start time: the state is stamped after tag operations that may block for seconds. + if ((this->nci_state_ == NCIState::EP_SELECTING || this->nci_state_ == NCIState::EP_DEACTIVATING) && + !this->irq_pin_->digital_read() && millis() - this->last_nci_state_change_ > NFCC_STATE_TIMEOUT) { + ESP_LOGW(TAG, "Timed out waiting for notification in state %u; resetting NFCC", (uint8_t) this->nci_state_); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + return; + } + if (this->irq_pin_->digital_read()) { + this->process_message_(); + } + break; + + case NCIState::TEST: + case NCIState::FAILED: + case NCIState::NONE: + default: + return; + } +} + +void PN71xx::nci_fsm_set_state_(NCIState new_state) { + ESP_LOGVV(TAG, "nci_fsm_set_state_(%u)", (uint8_t) new_state); + this->nci_state_ = new_state; + this->nci_state_error_ = NCIState::NONE; + this->error_count_ = 0; + this->last_nci_state_change_ = millis(); +} + +bool PN71xx::nci_fsm_set_error_state_(NCIState new_state) { + ESP_LOGVV(TAG, "nci_fsm_set_error_state_(%u); error_count_ = %u", (uint8_t) new_state, this->error_count_); + this->nci_state_error_ = new_state; + if (this->error_count_++ > NFCC_MAX_ERROR_COUNT) { + if ((this->nci_state_error_ == NCIState::NFCC_RESET) || (this->nci_state_error_ == NCIState::NFCC_INIT) || + (this->nci_state_error_ == NCIState::NFCC_CONFIG)) { + ESP_LOGE(TAG, "Too many initialization failures -- check device connections"); + this->mark_failed(); + this->nci_fsm_set_state_(NCIState::FAILED); + } else { + ESP_LOGW(TAG, "Too many errors transitioning to state %u; resetting NFCC", (uint8_t) this->nci_state_error_); + this->nci_fsm_set_state_(NCIState::NFCC_RESET); + } + } + return this->error_count_ > NFCC_MAX_ERROR_COUNT; +} + +void PN71xx::process_message_() { + nfc::NciMessage rx; + if (this->read_nfcc(rx, NFCC_DEFAULT_TIMEOUT) != nfc::STATUS_OK) { + return; // No data + } + + switch (rx.get_message_type()) { + case nfc::NCI_PKT_MT_CTRL_NOTIFICATION: + if (rx.get_gid() == nfc::RF_GID) { + switch (rx.get_oid()) { + case nfc::RF_INTF_ACTIVATED_OID: + ESP_LOGVV(TAG, "RF_INTF_ACTIVATED_OID"); + this->process_rf_intf_activated_oid_(rx); + return; + + case nfc::RF_DISCOVER_OID: + ESP_LOGVV(TAG, "RF_DISCOVER_OID"); + this->process_rf_discover_oid_(rx); + return; + + case nfc::RF_DEACTIVATE_OID: + ESP_LOGVV(TAG, "RF_DEACTIVATE_OID: type: 0x%02X, reason: 0x%02X", rx.get_message()[3], rx.get_message()[4]); + this->process_rf_deactivate_oid_(rx); + return; + + default: + ESP_LOGV(TAG, "Unimplemented RF OID received: 0x%02X", rx.get_oid()); + } + } else if (rx.get_gid() == nfc::NCI_CORE_GID) { + switch (rx.get_oid()) { + case nfc::NCI_CORE_GENERIC_ERROR_OID: + ESP_LOGV(TAG, "NCI_CORE_GENERIC_ERROR_OID:"); + switch (rx.get_simple_status_response()) { + case nfc::DISCOVERY_ALREADY_STARTED: + ESP_LOGV(TAG, " DISCOVERY_ALREADY_STARTED"); + break; + + case nfc::DISCOVERY_TARGET_ACTIVATION_FAILED: + // Tag removed too soon + ESP_LOGV(TAG, " DISCOVERY_TARGET_ACTIVATION_FAILED"); + if (this->nci_state_ == NCIState::EP_SELECTING) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + if (!this->discovered_endpoint_.empty()) { + this->erase_tag_(this->selecting_endpoint_); + } + } else { + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + break; + + case nfc::DISCOVERY_TEAR_DOWN: + ESP_LOGV(TAG, " DISCOVERY_TEAR_DOWN"); + break; + + default: + ESP_LOGW(TAG, "Unknown error: 0x%02X", rx.get_simple_status_response()); + break; + } + break; + + default: + ESP_LOGV(TAG, "Unimplemented NCI Core OID received: 0x%02X", rx.get_oid()); + } + } else { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented notification: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + break; + + case nfc::NCI_PKT_MT_CTRL_RESPONSE: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented GID: 0x%02X OID: 0x%02X Full response: %s", rx.get_gid(), rx.get_oid(), + nfc::format_bytes_to(buf, rx.get_message())); + break; + } + + case nfc::NCI_PKT_MT_CTRL_COMMAND: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented command: %s", nfc::format_bytes_to(buf, rx.get_message())); + break; + } + + case nfc::NCI_PKT_MT_DATA: + this->process_data_message_(rx); + break; + + default: { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGV(TAG, "Unimplemented message type: %s", nfc::format_bytes_to(buf, rx.get_message())); + break; + } + } +} + +void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoint was activated + uint8_t discovery_id = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_DISCOVERY_ID); + uint8_t interface = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_INTERFACE); + uint8_t protocol = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_PROTOCOL); + uint8_t mode_tech = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MODE_TECH); + uint8_t max_size = rx.get_message_byte(nfc::RF_INTF_ACTIVATED_NTF_MAX_SIZE); + + ESP_LOGVV(TAG, "Endpoint activated -- interface: 0x%02X, protocol: 0x%02X, mode&tech: 0x%02X, max payload: %u", + interface, protocol, mode_tech, max_size); + + if (mode_tech & nfc::MODE_LISTEN_MASK) { + ESP_LOGVV(TAG, "Tag activated in listen mode"); + this->nci_fsm_set_state_(NCIState::RFST_LISTEN_ACTIVE); + return; + } + + this->nci_fsm_set_state_(NCIState::RFST_POLL_ACTIVE); + if (rx.get_message().size() < nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS) { + ESP_LOGE(TAG, "RF_INTF_ACTIVATED_NTF too short"); + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); + return; + } + auto incoming_tag = + this->build_tag_(mode_tech, protocol, + std::vector(rx.get_message().begin() + nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS, + rx.get_message().end())); + + if (incoming_tag == nullptr) { + ESP_LOGE(TAG, "Could not build tag"); + } else { + auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); + if (tag_loc.has_value()) { + this->discovered_endpoint_[tag_loc.value()].id = discovery_id; + this->discovered_endpoint_[tag_loc.value()].protocol = protocol; + this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); + ESP_LOGVV(TAG, "Tag cache updated"); + } else { + this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), + .tag = std::move(incoming_tag), + .id = discovery_id, + .protocol = protocol, + .trig_called = false}); + tag_loc = this->discovered_endpoint_.size() - 1; + ESP_LOGVV(TAG, "Tag added to cache"); + } + + auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; + + switch (this->next_task_) { + case EP_CLEAN: + ESP_LOGD(TAG, " Tag cleaning"); + if (this->clean_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Tag cleaning incomplete"); + } + ESP_LOGD(TAG, " Tag cleaned!"); + break; + + case EP_FORMAT: + ESP_LOGD(TAG, " Tag formatting"); + if (this->format_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error formatting tag as NDEF"); + } + ESP_LOGD(TAG, " Tag formatted!"); + break; + + case EP_WRITE: + if (this->next_task_message_to_write_ != nullptr) { + ESP_LOGD(TAG, " Tag writing\n" + " Tag formatting"); + if (this->format_endpoint_(working_endpoint.protocol) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Tag could not be formatted for writing"); + } else { + ESP_LOGD(TAG, " Writing NDEF data"); + if (this->write_endpoint_(working_endpoint.protocol, working_endpoint.tag->get_uid(), + this->next_task_message_to_write_) != nfc::STATUS_OK) { + ESP_LOGE(TAG, " Failed to write message to tag"); + } + ESP_LOGD(TAG, " Finished writing NDEF data"); + this->next_task_message_to_write_ = nullptr; + this->on_finished_write_callback_.call(); + } + } + break; + + case EP_READ: + default: + if (!working_endpoint.trig_called) { + char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; + ESP_LOGI(TAG, "Read tag type %s with UID %s", working_endpoint.tag->get_tag_type().c_str(), + nfc::format_uid_to(uid_buf, working_endpoint.tag->get_uid())); + if (this->read_endpoint_data_(working_endpoint.protocol, *working_endpoint.tag) != nfc::STATUS_OK) { + ESP_LOGW(TAG, " Unable to read NDEF record(s)"); + } else if (working_endpoint.tag->has_ndef_message()) { + const auto message = working_endpoint.tag->get_ndef_message(); + const auto records = message->get_records(); + ESP_LOGD(TAG, " NDEF record(s):"); + for (const auto &record : records) { + ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); + } + } else { + ESP_LOGW(TAG, " No NDEF records found"); + } + for (auto *trigger : this->triggers_ontag_) { + trigger->process(working_endpoint.tag); + } + for (auto *listener : this->tag_listeners_) { + listener->tag_on(*working_endpoint.tag); + } + working_endpoint.trig_called = true; + break; + } + } + // the tag was present for the whole operation, which may have taken longer than tag_ttl + working_endpoint.last_seen = millis(); + if (working_endpoint.protocol == nfc::PROT_MIFARE) { + this->halt_mifare_classic_tag_(); + } + } + if (this->next_task_ != EP_READ) { + this->read_mode(); + } + + this->stop_discovery_(); + this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); +} + +void PN71xx::process_rf_discover_oid_(nfc::NciMessage &rx) { + if (rx.get_message().size() < nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS) { + ESP_LOGE(TAG, "RF_DISCOVER_NTF too short"); + return; + } + auto incoming_tag = this->build_tag_( + rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), + std::vector(rx.get_message().begin() + nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS, rx.get_message().end())); + + if (incoming_tag == nullptr) { + ESP_LOGE(TAG, "Could not build tag!"); + } else { + auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); + if (tag_loc.has_value()) { + this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); + this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); + this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); + ESP_LOGVV(TAG, "Tag found & updated"); + } else { + this->discovered_endpoint_.emplace_back( + DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), + .tag = std::move(incoming_tag), + .id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), + .protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), + .trig_called = false}); + ESP_LOGVV(TAG, "Tag saved"); + } + } + + if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + ESP_LOGVV(TAG, "Discovered %zu endpoints", this->discovered_endpoint_.size()); + } +} + +void PN71xx::process_rf_deactivate_oid_(nfc::NciMessage &rx) { + this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; + + switch (rx.get_simple_status_response()) { + case nfc::DEACTIVATION_TYPE_DISCOVERY: + this->nci_fsm_set_state_(NCIState::RFST_DISCOVERY); + break; + + case nfc::DEACTIVATION_TYPE_IDLE: + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + break; + + case nfc::DEACTIVATION_TYPE_SLEEP: + case nfc::DEACTIVATION_TYPE_SLEEP_AF: + if (this->nci_state_ == NCIState::RFST_LISTEN_ACTIVE) { + this->nci_fsm_set_state_(NCIState::RFST_LISTEN_SLEEP); + } else if (this->nci_state_ == NCIState::RFST_POLL_ACTIVE) { + this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); + } else { + this->nci_fsm_set_state_(NCIState::RFST_IDLE); + } + break; + + default: + break; + } +} + +void PN71xx::process_data_message_(nfc::NciMessage &rx) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); + + std::vector ndef_response; + this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); + + if (ndef_response.empty()) { + return; // no message returned, we cannot respond + } + // the CC limits MLe so every response fits in a single data packet (payload length is one byte) + if (ndef_response.size() > UINT8_MAX) { + ESP_LOGE(TAG, "Card emulation response too long: %zu bytes", ndef_response.size()); + return; + } + + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, ndef_response); + ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); + if (this->transceive_(tx, rx, NFCC_DEFAULT_TIMEOUT, false) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Sending reply for card emulation failed"); + } +} + +bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, std::vector &ndef_response) { + const auto &ndef_message = this->card_emulation_ndef_; + // the NDEF file is a two-byte big-endian length (NLEN) followed by the message + const uint16_t ndef_msg_size = ndef_message.size(); + const uint32_t file_size = ndef_msg_size + 2; + if (offset + static_cast(length) > file_size) { + return false; + } + for (uint32_t i = offset; i < offset + static_cast(length); i++) { + if (i == 0) { + ndef_response.push_back(ndef_msg_size >> 8); + } else if (i == 1) { + ndef_response.push_back(ndef_msg_size & 0xFF); + } else { + ndef_response.push_back(ndef_message[i - 2]); + } + } + if (offset + static_cast(length) == file_size) { + ESP_LOGD(TAG, "NDEF message sent"); + this->on_emulated_tag_scan_callback_.call(); + } + return true; +} + +void PN71xx::card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response) { + ndef_response.clear(); + if (this->card_emulation_ndef_.empty()) { + ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); + return; + } + if (response.size() < nfc::NCI_PKT_HEADER_SIZE) { + return; + } + + const auto apdu_begin = response.begin() + nfc::NCI_PKT_HEADER_SIZE; + const size_t apdu_size = response.size() - nfc::NCI_PKT_HEADER_SIZE; + auto apdu_is = [&](const uint8_t *cmd, size_t cmd_size) { + return apdu_size == cmd_size && std::equal(apdu_begin, response.end(), cmd); + }; + auto apdu_starts_with = [&](const uint8_t *cmd, size_t cmd_size) { + return apdu_size >= cmd_size && std::equal(cmd, cmd + cmd_size, apdu_begin); + }; + bool ok = false; + + if (apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT)) || + apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT) - 1)) { // Le is optional + ESP_LOGVV(TAG, "CARD_EMU_NDEF_APP_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_APP_SELECTED; + ok = true; + } else if (apdu_is(CARD_EMU_T4T_CC_SELECT, sizeof(CARD_EMU_T4T_CC_SELECT))) { + if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_APP_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_CC_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_CC_SELECTED; + ok = true; + } + } else if (apdu_is(CARD_EMU_T4T_NDEF_SELECT, sizeof(CARD_EMU_T4T_NDEF_SELECT))) { + ESP_LOGVV(TAG, "CARD_EMU_NDEF_SELECTED"); + this->ce_state_ = CardEmulationState::CARD_EMU_NDEF_SELECTED; + ok = true; + } else if (apdu_starts_with(CARD_EMU_T4T_READ, sizeof(CARD_EMU_T4T_READ)) && apdu_size == 5) { + // READ BINARY: CLA INS P1 P2 Le, where P1-P2 is the offset + const uint16_t offset = (apdu_begin[2] << 8) | apdu_begin[3]; + const uint8_t length = apdu_begin[4]; + if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); + if (offset + static_cast(length) <= sizeof(CARD_EMU_T4T_CC)) { + ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_CC) + offset, + std::begin(CARD_EMU_T4T_CC) + offset + length); + ok = true; + } + } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_NDEF_SELECTED"); + ok = this->card_emu_t4t_read_ndef_(offset, length, ndef_response); + } + } else if (apdu_starts_with(CARD_EMU_T4T_WRITE, sizeof(CARD_EMU_T4T_WRITE)) && apdu_size >= 5) { + // UPDATE BINARY: CLA INS P1 P2 Lc data + const uint8_t length = apdu_begin[4]; + if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED && apdu_size >= 5u + length) { + ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); + std::vector ndef_msg_written(apdu_begin + 5, apdu_begin + 5 + length); + char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(write_buf, ndef_msg_written)); + ok = true; + } + } + + if (ok) { + ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); + } else { + ndef_response.clear(); + ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_NOK), std::end(CARD_EMU_T4T_NOK)); + this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; + } +} + +uint8_t PN71xx::transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, const uint16_t timeout, + const bool expect_notification) { + char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + + // The NFCC may refuse a frame while it wakes from standby; the whole frame must then be sent again. + // A command must never be re-sent once it was accepted, as the NFCC will answer it (NCI 1.0, 3.2.1). + uint8_t attempts = 0; + while (this->write_nfcc(tx) != nfc::STATUS_OK) { + if (++attempts >= NFCC_MAX_COMM_FAILS) { + ESP_LOGE(TAG, "Error sending message"); + return nfc::STATUS_FAILED; + } + delay(NFCC_WRITE_RETRY_DELAY); + } + ESP_LOGVV(TAG, "Wrote: %s", nfc::format_bytes_to(buf, tx.get_message())); + + if (!tx.message_type_is(nfc::NCI_PKT_MT_DATA)) { + // Notifications may already be queued ahead of the response; skip them. They carry the same GID and OID + // as some responses (e.g. RF_DEACTIVATE_NTF), so the message type must be checked. A response to an earlier + // command whose read timed out (a tag leaving the field delays RF_DEACTIVATE_RSP) is skipped the same way. + for (uint8_t i = 0; i < NFCC_MAX_COMM_FAILS; i++) { + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error receiving response"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + if (rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE) && rx.get_gid() == tx.get_gid() && + rx.get_oid() == tx.get_oid()) { + break; + } + ESP_LOGW(TAG, "Discarding message received while waiting for response: %s", + nfc::format_bytes_to(buf, rx.get_message())); + } + // for commands, the GID and OID should match and the status should be OK + if (!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_RESPONSE) || (rx.get_gid() != tx.get_gid()) || + (rx.get_oid() != tx.get_oid())) { + ESP_LOGE(TAG, "Incorrect response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + if (!rx.simple_status_response_is(nfc::STATUS_OK)) { + ESP_LOGE(TAG, "Error in response to command: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + return rx.get_simple_status_response(); + } + + // when sending data to the endpoint, the first message is the credit notification from the NFCC + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGW(TAG, "Error receiving credit notification"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + if ((!rx.message_type_is(nfc::NCI_PKT_MT_CTRL_NOTIFICATION)) || (!rx.gid_is(nfc::NCI_CORE_GID)) || + (!rx.oid_is(nfc::NCI_CORE_CONN_CREDITS_OID)) || (!rx.message_length_is(3))) { + ESP_LOGE(TAG, "Incorrect response to data message: %s", nfc::format_bytes_to(buf, rx.get_message())); + return nfc::STATUS_FAILED; + } + + if (expect_notification) { + // the endpoint's answer follows in a data message + if (this->read_nfcc(rx, timeout) != nfc::STATUS_OK) { + ESP_LOGE(TAG, "Error receiving data from endpoint"); + return nfc::STATUS_FAILED; + } + ESP_LOGVV(TAG, "Read: %s", nfc::format_bytes_to(buf, rx.get_message())); + } + + return nfc::STATUS_OK; +} + +uint8_t PN71xx::wait_for_irq_(uint16_t timeout, bool pin_state) { + auto start_time = millis(); + + while (millis() - start_time < timeout) { + if (this->irq_pin_->digital_read() == pin_state) { + return nfc::STATUS_OK; + } + } + return nfc::STATUS_FAILED; +} + +} // namespace esphome::pn71xx diff --git a/esphome/components/pn71xx/pn71xx.h b/esphome/components/pn71xx/pn71xx.h new file mode 100644 index 00000000000..f9cfddc2e92 --- /dev/null +++ b/esphome/components/pn71xx/pn71xx.h @@ -0,0 +1,306 @@ +#pragma once + +#include "esphome/components/nfc/automation.h" +#include "esphome/components/nfc/nci_core.h" +#include "esphome/components/nfc/nci_message.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/nfc/nfc_helpers.h" +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::pn71xx { + +// Time to wait for the NFCC to answer. NXP's reference stack waits 1 s for a response; 10 ms was short enough +// that a slow RF_DEACTIVATE_RSP, for example when a tag leaves the field, caused a full NFCC reset. +static constexpr uint16_t NFCC_DEFAULT_TIMEOUT = 100; +static constexpr uint16_t NFCC_INIT_TIMEOUT = 50; +static constexpr uint16_t NFCC_TAG_WRITE_TIMEOUT = 100; +// Time to wait for IRQ to drop after a read; it drops within microseconds unless another message is queued +static constexpr uint16_t NFCC_IRQ_CLEAR_TIMEOUT = 5; +// Length of the VEN and DWL_REQ pulses when resetting the NFCC +static constexpr uint16_t NFCC_RESET_DELAY = 10; +// Time to wait before resending a frame the NFCC refused, e.g. while waking from standby +static constexpr uint16_t NFCC_WRITE_RETRY_DELAY = 5; +// Longest time the FSM may wait for a notification that ends a transitional state before resetting the NFCC +static constexpr uint32_t NFCC_STATE_TIMEOUT = 1000; + +static constexpr uint8_t NFCC_MAX_COMM_FAILS = 3; +static constexpr uint8_t NFCC_MAX_ERROR_COUNT = 10; + +static constexpr uint8_t XCHG_DATA_OID = 0x10; +static constexpr uint8_t MF_SECTORSEL_OID = 0x32; +static constexpr uint8_t MFC_AUTHENTICATE_OID = 0x40; +static constexpr uint8_t TEST_PRBS_OID = 0x30; +static constexpr uint8_t TEST_ANTENNA_OID = 0x3D; +static constexpr uint8_t TEST_GET_REGISTER_OID = 0x33; + +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_A = 0x00; // key select A +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_KS_B = 0x80; // key select B +static constexpr uint8_t MFC_AUTHENTICATE_PARAM_EMBED_KEY = 0x10; + +static constexpr uint8_t CARD_EMU_T4T_APP_SELECT[] = {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, + 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}; +// MLe is 0xFD so a full-length READ BINARY response plus the two status bytes fits in one NCI data packet +static constexpr uint8_t CARD_EMU_T4T_CC[] = {0x00, 0x0F, 0x20, 0x00, 0xFD, 0x00, 0xFF, 0x04, + 0x06, 0xE1, 0x04, 0x00, 0xFF, 0x00, 0x00}; +// Largest NDEF message that fits in the emulated NDEF file (max file size in the CC, less the 2-byte length) +static constexpr uint16_t CARD_EMU_T4T_MAX_NDEF_SIZE = 0xFF - 2; +static constexpr uint8_t CARD_EMU_T4T_CC_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}; +static constexpr uint8_t CARD_EMU_T4T_NDEF_SELECT[] = {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}; +static constexpr uint8_t CARD_EMU_T4T_READ[] = {0x00, 0xB0}; +static constexpr uint8_t CARD_EMU_T4T_WRITE[] = {0x00, 0xD6}; +static constexpr uint8_t CARD_EMU_T4T_OK[] = {0x90, 0x00}; +static constexpr uint8_t CARD_EMU_T4T_NOK[] = {0x6A, 0x82}; + +static constexpr uint8_t CORE_CONFIG_SOLO[] = {0x01, // Number of parameter fields + 0x00, // config param identifier (TOTAL_DURATION) + 0x02, // length of value + 0x01, // TOTAL_DURATION (low)... + 0x00}; // TOTAL_DURATION (high): 1 ms + +static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter fields + 0x00, // config param identifier (TOTAL_DURATION) + 0x02, // length of value + 0xF8, // TOTAL_DURATION (low)... + 0x02}; // TOTAL_DURATION (high): 760 ms + +static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes + nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_T3T, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_FRAME, // poll mode + nfc::PROT_ISODEP, nfc::RF_DISCOVER_MAP_MODE_POLL | nfc::RF_DISCOVER_MAP_MODE_LISTEN, + nfc::INTF_ISODEP, // poll & listen mode + nfc::PROT_MIFARE, nfc::RF_DISCOVER_MAP_MODE_POLL, + nfc::INTF_TAGCMD}; // poll mode + +static constexpr uint8_t RF_DISCOVERY_LISTEN_CONFIG[] = { + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode + +static constexpr uint8_t RF_DISCOVERY_POLL_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF}; // poll mode + +static constexpr uint8_t RF_DISCOVERY_CONFIG[] = {nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCB, // poll mode + nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCF, // poll mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCA, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCB, // listen mode + nfc::MODE_LISTEN_MASK | nfc::TECH_PASSIVE_NFCF}; // listen mode + +enum class CardEmulationState : uint8_t { + CARD_EMU_IDLE, + CARD_EMU_NDEF_APP_SELECTED, + CARD_EMU_CC_SELECTED, + CARD_EMU_NDEF_SELECTED, + CARD_EMU_DESFIRE_PROD, +}; + +enum class NCIState : uint8_t { + NONE = 0x00, + NFCC_RESET, + NFCC_INIT, + NFCC_CONFIG, + NFCC_SET_DISCOVER_MAP, + NFCC_SET_LISTEN_MODE_ROUTING, + RFST_IDLE, + RFST_DISCOVERY, + RFST_W4_ALL_DISCOVERIES, + RFST_W4_HOST_SELECT, + RFST_LISTEN_ACTIVE, + RFST_LISTEN_SLEEP, + RFST_POLL_ACTIVE, + EP_DEACTIVATING, + EP_SELECTING, + TEST = 0xFE, + FAILED = 0xFF, +}; + +enum class TestMode : uint8_t { + TEST_NONE = 0x00, + TEST_PRBS, + TEST_ANTENNA, + TEST_GET_REGISTER, +}; + +struct DiscoveredEndpoint { + uint32_t last_seen; + std::unique_ptr tag; + uint8_t id; + uint8_t protocol; + bool trig_called; +}; + +/// Common driver for the NXP PN71xx family of NCI NFC controllers. The chip classes (PN7150, PN7160) supply the parts +/// that differ between chips; the bus classes supply read_nfcc() and write_nfcc(). +class PN71xx : public nfc::Nfcc, public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_irq_pin(GPIOPin *irq_pin) { this->irq_pin_ = irq_pin; } + void set_ven_pin(GPIOPin *ven_pin) { this->ven_pin_ = ven_pin; } + + void set_tag_ttl(uint32_t ttl) { this->tag_ttl_ = ttl; } + void set_tag_emulation_message(const std::shared_ptr &message); + void set_tag_emulation_message(const std::string &message, bool include_android_app_record = true); + void set_tag_emulation_message(const char *message, bool include_android_app_record = true); + void set_tag_emulation_off(); + void set_tag_emulation_on(); + bool tag_emulation_enabled() { return this->listening_enabled_; } + + void set_polling_off(); + void set_polling_on(); + bool polling_enabled() { return this->polling_enabled_; } + + void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } + void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } + + template void add_on_emulated_tag_scan_callback(F &&callback) { + this->on_emulated_tag_scan_callback_.add(std::forward(callback)); + } + + template void add_on_finished_write_callback(F &&callback) { + this->on_finished_write_callback_.add(std::forward(callback)); + } + + bool is_writing() { return this->next_task_ != EP_READ; }; + + void read_mode(); + void clean_mode(); + void format_mode(); + void write_mode(); + void set_tag_write_message(std::shared_ptr message); + void set_tag_write_message(const std::string &message, bool include_android_app_record = true); + + uint8_t set_test_mode(TestMode test_mode, const std::vector &data, std::vector &result); + + protected: + uint8_t reset_core_(bool reset_config, bool power); + uint8_t init_core_(); + + /// Chip hooks + /// Called before the NFCC is reset, e.g. to make sure it will not start in firmware download mode + virtual void prepare_reset() {} + /// Validates what follows a successful CORE_RESET_RSP (in `rx`); may read more messages from the NFCC + virtual uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) = 0; + /// Logs chip information from a successful CORE_INIT_RSP + virtual uint8_t process_init_response(nfc::NciMessage &rx) = 0; + /// Parameters for the CORE_SET_CONFIG_CMD that configures the power management unit + virtual std::span pmu_config() const = 0; + /// Payload of the RF_SET_LISTEN_MODE_ROUTING_CMD + virtual std::span listen_mode_routing_config() const = 0; + + uint8_t send_init_config_(); + uint8_t send_core_config_(); + uint8_t refresh_core_config_(); + + uint8_t set_discover_map_(); + + uint8_t set_listen_mode_routing_(); + + uint8_t start_discovery_(); + uint8_t stop_discovery_(); + uint8_t deactivate_(uint8_t type, uint16_t timeout = NFCC_DEFAULT_TIMEOUT); + + void select_endpoint_(); + + uint8_t read_endpoint_data_(uint8_t protocol, nfc::NfcTag &tag); + uint8_t clean_endpoint_(uint8_t protocol); + uint8_t format_endpoint_(uint8_t protocol); + uint8_t write_endpoint_(uint8_t protocol, nfc::NfcTagUid &uid, std::shared_ptr &message); + + std::unique_ptr build_tag_(uint8_t mode_tech, uint8_t protocol, const std::vector &data); + optional find_tag_uid_(const nfc::NfcTagUid &uid); + void purge_old_tags_(); + void erase_tag_(uint8_t tag_index); + + /// advance controller state as required + void nci_fsm_transition_(); + /// set new controller state + void nci_fsm_set_state_(NCIState new_state); + /// setting controller to this state caused an error; returns true if too many errors/failures + bool nci_fsm_set_error_state_(NCIState new_state); + /// parse & process incoming messages from the NFCC + void process_message_(); + void process_rf_intf_activated_oid_(nfc::NciMessage &rx); + void process_rf_discover_oid_(nfc::NciMessage &rx); + void process_rf_deactivate_oid_(nfc::NciMessage &rx); + void process_data_message_(nfc::NciMessage &rx); + + void card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response); + bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, std::vector &ndef_response); + + uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, + bool expect_notification = true); + virtual uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) = 0; + virtual uint8_t write_nfcc(nfc::NciMessage &tx) = 0; + + uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); + + uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); + uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); + uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); + uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); + uint8_t sect_to_auth_(uint8_t block_num); + uint8_t format_mifare_classic_mifare_(); + uint8_t format_mifare_classic_ndef_(); + uint8_t write_mifare_classic_tag_(const std::shared_ptr &message); + uint8_t halt_mifare_classic_tag_(); + + uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); + uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); + bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + uint16_t read_mifare_ultralight_capacity_(); + uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + uint8_t &message_start_index); + uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); + uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); + uint8_t clean_mifare_ultralight_(); + + enum NfcTask : uint8_t { + EP_READ = 0, + EP_CLEAN, + EP_FORMAT, + EP_WRITE, + }; + + // members are ordered by alignment, widest first, to minimize padding + CallbackManager on_emulated_tag_scan_callback_; + CallbackManager on_finished_write_callback_; + + std::vector discovered_endpoint_; + std::vector card_emulation_ndef_; // encoded emulation message; empty when none is set + std::vector triggers_ontag_; + std::vector triggers_ontagremoved_; + std::shared_ptr next_task_message_to_write_; + + GPIOPin *irq_pin_{nullptr}; + GPIOPin *ven_pin_{nullptr}; + + uint32_t last_nci_state_change_{0}; + uint32_t tag_ttl_{250}; + + NfcTask next_task_{EP_READ}; + CardEmulationState ce_state_{CardEmulationState::CARD_EMU_IDLE}; + NCIState nci_state_{NCIState::NFCC_RESET}; + NCIState nci_state_error_{NCIState::NONE}; + uint8_t error_count_{0}; + uint8_t selecting_endpoint_{0}; + + bool config_refresh_pending_{false}; + bool core_config_is_solo_{false}; + bool listening_enabled_{false}; + bool polling_enabled_{true}; +}; + +} // namespace esphome::pn71xx diff --git a/esphome/components/pn7160/pn7160_mifare_classic.cpp b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp similarity index 94% rename from esphome/components/pn7160/pn7160_mifare_classic.cpp rename to esphome/components/pn71xx/pn71xx_mifare_classic.cpp index 0dc8bbdfe46..0bd6148e8a8 100644 --- a/esphome/components/pn7160/pn7160_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -1,14 +1,14 @@ #include #include -#include "pn7160.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7160 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7160.mifare_classic"; +static const char *const TAG = "pn71xx.mifare_classic"; -uint8_t PN7160::read_mifare_classic_tag_(nfc::NfcTag &tag) { +uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { uint8_t current_block = 4; uint8_t message_start_index = 0; uint32_t message_length = 0; @@ -66,7 +66,7 @@ uint8_t PN7160::read_mifare_classic_tag_(nfc::NfcTag &tag) { return nfc::STATUS_OK; } -uint8_t PN7160::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { +uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -90,7 +90,7 @@ uint8_t PN7160::read_mifare_classic_block_(uint8_t block_num, std::vectorsect_to_auth_(block_num), key_num}); @@ -129,7 +129,7 @@ uint8_t PN7160::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, c return nfc::STATUS_OK; } -uint8_t PN7160::sect_to_auth_(const uint8_t block_num) { +uint8_t PN71xx::sect_to_auth_(const uint8_t block_num) { const uint8_t first_high_block = nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW * nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; if (block_num >= first_high_block) { return ((block_num - first_high_block) / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_HIGH) + @@ -138,7 +138,7 @@ uint8_t PN7160::sect_to_auth_(const uint8_t block_num) { return block_num / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW; } -uint8_t PN7160::format_mifare_classic_mifare_() { +uint8_t PN71xx::format_mifare_classic_mifare_() { static constexpr std::array BLANK_BUFFER = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array TRAILER_BUFFER = { @@ -173,7 +173,7 @@ uint8_t PN7160::format_mifare_classic_mifare_() { return status; } -uint8_t PN7160::format_mifare_classic_ndef_() { +uint8_t PN71xx::format_mifare_classic_ndef_() { static constexpr std::array EMPTY_NDEF_MESSAGE = { 0x03, 0x03, 0xD0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array BLANK_BLOCK = { @@ -237,7 +237,7 @@ uint8_t PN7160::format_mifare_classic_ndef_() { return status; } -uint8_t PN7160::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { +uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_WRITE, block_num}); char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -267,7 +267,7 @@ uint8_t PN7160::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da return nfc::STATUS_OK; } -uint8_t PN7160::write_mifare_classic_tag_(const std::shared_ptr &message) { +uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptr &message) { auto encoded = message->encode(); uint32_t message_length = encoded.size(); @@ -310,7 +310,7 @@ uint8_t PN7160::write_mifare_classic_tag_(const std::shared_ptr #include -#include "pn7150.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7150 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7150.mifare_ultralight"; +static const char *const TAG = "pn71xx.mifare_ultralight"; -uint8_t PN7150::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { +uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { std::vector data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, @@ -50,18 +50,24 @@ uint8_t PN7150::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { return nfc::STATUS_OK; } -uint8_t PN7150::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { +uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); for (size_t i = 0; i * read_increment < num_bytes; i++) { tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; - do { // loop because sometimes we struggle here...???... + // a short answer (e.g. a NAK for a page beyond the end of the tag) is retried a limited number of times + uint8_t attempts = 0; + do { if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading tag data"); return nfc::STATUS_FAILED; } + if (++attempts > NFCC_MAX_COMM_FAILS && rx.get_payload_size() < read_increment) { + ESP_LOGE(TAG, "Short read from page %u", tx.get_message().back()); + return nfc::STATUS_FAILED; + } } while (rx.get_payload_size() < read_increment); uint16_t bytes_offset = (i + 1) * read_increment; auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 @@ -78,7 +84,7 @@ uint8_t PN7150::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_OK; } -bool PN7150::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN71xx::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -86,7 +92,7 @@ bool PN7150::is_mifare_ultralight_formatted_(const std::vector &page_3_ (page_3_to_6[p4_offset + 2] != 0xFF) || (page_3_to_6[p4_offset + 3] != 0xFF)); } -uint16_t PN7150::read_mifare_ultralight_capacity_() { +uint16_t PN71xx::read_mifare_ultralight_capacity_() { std::vector data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); @@ -95,7 +101,7 @@ uint16_t PN7150::read_mifare_ultralight_capacity_() { return 0; } -uint8_t PN7150::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -115,7 +121,7 @@ uint8_t PN7150::find_mifare_ultralight_ndef_(const std::vector &page_3_ return nfc::STATUS_FAILED; } -uint8_t PN7150::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { +uint8_t PN71xx::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); auto encoded = message->encode(); @@ -154,7 +160,7 @@ uint8_t PN7150::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::sha return nfc::STATUS_OK; } -uint8_t PN7150::clean_mifare_ultralight_() { +uint8_t PN71xx::clean_mifare_ultralight_() { uint32_t capacity = this->read_mifare_ultralight_capacity_(); uint8_t pages = (capacity / nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) + nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; @@ -168,7 +174,7 @@ uint8_t PN7150::clean_mifare_ultralight_() { return nfc::STATUS_OK; } -uint8_t PN7150::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { +uint8_t PN71xx::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; payload.insert(payload.end(), write_data, write_data + len); @@ -182,4 +188,4 @@ uint8_t PN7150::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *w return nfc::STATUS_OK; } -} // namespace esphome::pn7150 +} // namespace esphome::pn71xx diff --git a/tests/components/pn71xx/pn71xx_test.cpp b/tests/components/pn71xx/pn71xx_test.cpp new file mode 100644 index 00000000000..ceb2d7305c5 --- /dev/null +++ b/tests/components/pn71xx/pn71xx_test.cpp @@ -0,0 +1,194 @@ +#include + +#include + +#include "esphome/components/pn71xx/pn71xx.h" + +namespace esphome::pn71xx { + +namespace { + +// Stands in for the bus: records every frame written and replays queued frames on read. +class FakePN71xx : public PN71xx { + public: + using PN71xx::card_emu_t4t_get_response_; + using PN71xx::transceive_; + + std::deque> to_read; + std::vector> written; + uint8_t write_failures{0}; + + protected: + uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override { return nfc::STATUS_OK; } + uint8_t process_init_response(nfc::NciMessage &rx) override { return nfc::STATUS_OK; } + std::span pmu_config() const override { return {}; } + std::span listen_mode_routing_config() const override { return {}; } + + uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) override { + if (this->to_read.empty()) + return nfc::STATUS_FAILED; + rx = nfc::NciMessage(this->to_read.front()); + this->to_read.pop_front(); + return nfc::STATUS_OK; + } + uint8_t write_nfcc(nfc::NciMessage &tx) override { + if (this->write_failures > 0) { + this->write_failures--; + return nfc::STATUS_FAILED; + } + this->written.push_back(tx.encode()); + return nfc::STATUS_OK; + } +}; + +std::vector apdu(std::initializer_list bytes) { + std::vector msg = {nfc::NCI_PKT_MT_DATA, 0x00, static_cast(bytes.size())}; + msg.insert(msg.end(), bytes); + return msg; +} + +std::vector respond(FakePN71xx &nfcc, std::initializer_list bytes) { + std::vector response; + nfcc.card_emu_t4t_get_response_(apdu(bytes), response); + return response; +} + +void select_ndef_file(FakePN71xx &nfcc) { + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}); +} + +const std::vector SW_OK = {0x90, 0x00}; +const std::vector SW_NOT_FOUND = {0x6A, 0x82}; + +} // namespace + +// A timed-out read must not cause the command to be sent again (NCI forbids a second command before the response). +TEST(PN71xxTransceive, ReadTimeoutDoesNotResend) { + FakePN71xx nfcc; + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +// A notification with the same GID/OID as the response (RF_DEACTIVATE_NTF) is not mistaken for it. +TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x06, 0x01, 0x00})); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +TEST(PN71xxTransceive, NotificationAloneIsNotAResponse) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); +} + +// A late response to an earlier, timed-out command must not be taken as the response to this one. +TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) { + FakePN71xx nfcc; + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); // RF_DEACTIVATE_RSP, arriving late + nfcc.to_read.push_back({0x41, 0x03, 0x01, 0x00}); // RF_DISCOVER_RSP + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x03, 0x01, 0x00})); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +// A refused write (e.g. NFCC in standby) is sent again. +TEST(PN71xxTransceive, RefusedWriteIsRetried) { + FakePN71xx nfcc; + nfcc.write_failures = 1; + nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); + nfc::NciMessage rx; + EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); + EXPECT_EQ(nfcc.written.size(), 1u); +} + +TEST(PN71xxCardEmulation, CcReadOutOfRangeIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + // the CC file is 15 bytes; reading 17 or reading far past its end must not return memory beyond it + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x11}), SW_NOT_FOUND); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x01, 0x00, 0x0F}), SW_NOT_FOUND); +} + +TEST(PN71xxCardEmulation, CcReadInRange) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); + respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); + auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x0F}); + ASSERT_EQ(response.size(), sizeof(CARD_EMU_T4T_CC) + 2); + EXPECT_TRUE(std::equal(std::begin(CARD_EMU_T4T_CC), std::end(CARD_EMU_T4T_CC), response.begin())); +} + +// Reading the NDEF file in small chunks returns NLEN followed by the message, in order. +TEST(PN71xxCardEmulation, ChunkedNdefReadMatchesFile) { + FakePN71xx nfcc; + auto message = std::make_shared(); + message->add_uri_record("https://www.home-assistant.io/tag/0123456789abcdef"); + const auto encoded = message->encode(); + nfcc.set_tag_emulation_message(message); + select_ndef_file(nfcc); + + std::vector expected = {static_cast(encoded.size() >> 8), + static_cast(encoded.size() & 0xFF)}; + expected.insert(expected.end(), encoded.begin(), encoded.end()); + + std::vector file; + for (size_t offset = 0; offset < expected.size(); offset += 5) { + const uint8_t length = std::min(5, expected.size() - offset); + auto response = + respond(nfcc, {0x00, 0xB0, static_cast(offset >> 8), static_cast(offset), length}); + ASSERT_EQ(response.size(), length + 2u); + EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); + file.insert(file.end(), response.begin(), response.end() - 2); + } + EXPECT_EQ(file, expected); +} + +TEST(PN71xxCardEmulation, NdefReadPastEndIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x02, 0xFD}), SW_NOT_FOUND); +} + +TEST(PN71xxCardEmulation, TruncatedApdusAreRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00}), SW_NOT_FOUND); + select_ndef_file(nfcc); + // UPDATE BINARY claiming 16 bytes of data but carrying only 2 + EXPECT_EQ(respond(nfcc, {0x00, 0xD6, 0x00, 0x00, 0x10, 0x00, 0x00}), SW_NOT_FOUND); +} + +// A message too large for the emulated NDEF file is refused when it is set, keeping the previous one. +TEST(PN71xxCardEmulation, OversizedMessageRejectedWhenSet) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/" + std::string(300, 'x'), false); + select_ndef_file(nfcc); + auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x02}); + ASSERT_EQ(response.size(), 4u); + EXPECT_LT((response[0] << 8) | response[1], 0xFF - 2); + EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); +} + +} // namespace esphome::pn71xx From 746691d5c2c6d4a7efdd18b160d80a308baa44e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 20:44:09 +0100 Subject: [PATCH 579/759] [esp8266] Add native toolchain that builds the Arduino core without PlatformIO (#18539) --- esphome/__main__.py | 90 ++- esphome/analyze_memory/toolchain.py | 4 + esphome/arduino8266/toolchain.py | 266 +++++++++ esphome/build_gen/arduino8266.py | 3 +- esphome/build_helpers/idedata.py | 19 +- esphome/build_helpers/native.py | 34 ++ esphome/components/esp8266/__init__.py | 175 ++++-- esphome/components/esp8266/const.py | 2 + esphome/core/config.py | 8 +- esphome/writer.py | 8 +- .../esp8266/test_toolchain_validation.py | 197 +++++++ tests/unit_tests/core/test_config.py | 9 - .../lazy_imports/native_upload_fast_path.py | 65 +++ .../unit_tests/test_arduino8266_toolchain.py | 548 ++++++++++++++++++ tests/unit_tests/test_lazy_imports.py | 28 +- tests/unit_tests/test_main.py | 267 ++++++++- tests/unit_tests/test_writer.py | 6 +- 17 files changed, 1641 insertions(+), 88 deletions(-) create mode 100644 esphome/arduino8266/toolchain.py create mode 100644 esphome/build_helpers/native.py create mode 100644 tests/unit_tests/components/esp8266/test_toolchain_validation.py create mode 100644 tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py create mode 100644 tests/unit_tests/test_arduino8266_toolchain.py diff --git a/esphome/__main__.py b/esphome/__main__.py index d68319e63a7..707ad036096 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks +from esphome.build_helpers.native import native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -817,7 +818,9 @@ def write_cpp_file() -> int: from esphome.build_gen import espidf espidf.write_project() - else: + elif not CORE.using_native_toolchain: + # Other native builds generate their project at compile time; + # never write a platformio.ini for them from esphome.build_gen import platformio platformio.write_project() @@ -859,20 +862,14 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() - from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS + from esphome.build_helpers.idedata import warn_if_idedata_missing - try: - if toolchain.get_idedata() is None: - _LOGGER.warning("No idedata was generated for this build") - except IDEDATA_BEST_EFFORT_ERRORS as err: - # The firmware already built; an idedata failure must not fail - # a successful build. - _LOGGER.warning( - "Could not generate idedata: %s (IDE, clang-tidy, and " - "memory-analysis data will be unavailable for this build)", - err, - ) - _LOGGER.debug("Idedata failure detail", exc_info=True) + warn_if_idedata_missing(toolchain.get_idedata) + elif CORE.using_native_toolchain: + raise EsphomeError( + f"Toolchain '{CORE.toolchain.value}' resolved but no platform " + "backend claimed the build" + ) else: from esphome.platformio import toolchain @@ -975,12 +972,15 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] - elif CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - flash_images = [ - FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0") - ] + elif (native := native_backend()) is not None: + # Every native backend supplies its own 0x0 flash image (bootloader + # and partitions included where the target needs them) + image = native.get_factory_firmware_path() + if not image.is_file(): + raise EsphomeError( + f"{image} does not exist; compile the configuration first" + ) + flash_images = [FlashImage(path=image, offset="0x0")] else: from esphome.platformio import toolchain @@ -1970,12 +1970,12 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json - if CORE.using_toolchain_esp_idf: - # Native ESP-IDF derives idedata from the build's compile_commands.json, - # so the configuration must already be compiled. - from esphome.espidf import toolchain as espidf_toolchain + native_toolchain = native_backend() - idedata = espidf_toolchain.get_idedata() + if native_toolchain is not None: + # Native toolchains derive idedata from the build's + # compile_commands.json, so the configuration must already be compiled. + idedata = native_toolchain.get_idedata() if idedata is None: _LOGGER.error( "No idedata available; compile the configuration first", @@ -2014,6 +2014,17 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.cli import MemoryAnalyzerCLI from esphome.analyze_memory.ram_strings import RamStringsAnalyzer + # Refuse an unsupported toolchain before paying for a full compile + native_toolchain = native_backend() + if native_toolchain is None and not CORE.using_toolchain_platformio: + _LOGGER.error( + "analyze-memory is not supported with the '%s' toolchain on %s; " + "re-run with --toolchain platformio", + CORE.toolchain.value if CORE.toolchain else "unresolved", + CORE.target_platform, + ) + return 1 + # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) if exit_code != 0: @@ -2025,13 +2036,30 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain + if native_toolchain is not None: + objdump = native_toolchain.get_objdump_path() + readelf = native_toolchain.get_readelf_path() + for tool in (objdump, readelf): + if not tool.is_file(): + # The analyzer would silently fall back to host + # binutils, which cannot read the target ELF + _LOGGER.error( + "%s is missing; the toolchain install may be incomplete " + "(recompile, or run 'esphome clean-all' if it persists)", + tool, + ) + return 1 + objdump_path = str(objdump) + readelf_path = str(readelf) - objdump_path = str(toolchain.get_objdump_path()) - readelf_path = str(toolchain.get_readelf_path()) - - firmware_elf = toolchain.get_elf_path() + firmware_elf = native_toolchain.get_elf_path() + if not firmware_elf.is_file(): + # The analyzer swallows tool failures, so a missing ELF would + # produce an exit-0 zeroed report + _LOGGER.error( + "%s is missing; compile the configuration first", firmware_elf + ) + return 1 else: from esphome.platformio import toolchain diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 19041ac8071..b64af5ea5b8 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -68,12 +68,16 @@ def idedata_candidates(build_path: Path) -> list[Path]: The candidate idedata JSON paths, most specific first """ name = build_path.name + data_dir = build_path.parent.parent / "idedata" + # Native backends suffix the cache by toolchain (.arduino.json) + suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else [] return [ # In .pioenvs for test builds build_path / ".pioenvs" / name / "idedata.json", # Both toolchains cache it in the data dir, which holds this build dir: # /idedata/.json next to /build/ build_path.parent.parent / "idedata" / f"{name}.json", + *suffixed, # Regular builds, invoked from the config dir or from anywhere Path.cwd() / ".esphome" / "idedata" / f"{name}.json", Path.home() / ".esphome" / "idedata" / f"{name}.json", diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py new file mode 100644 index 00000000000..339fb44337f --- /dev/null +++ b/esphome/arduino8266/toolchain.py @@ -0,0 +1,266 @@ +"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent).""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +import subprocess +from typing import TYPE_CHECKING + +from esphome.build_helpers.ccache import resolve_ccache_path +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_FRAMEWORK_VERSION, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import write_file +from esphome.types import ConfigType + +if TYPE_CHECKING: + from esphome.arduino8266.framework import InstalledPaths + +_LOGGER = logging.getLogger(__name__) + +# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest) +_MAX_RAM_SIZE = 81920 + + +def _warn_ignored_platformio_options() -> None: + """Warn for component-added platformio options the native build drops.""" + from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS + + consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS + for key in sorted(CORE.platformio_options or {}): + if key not in consumed: + _LOGGER.warning( + "platformio_options->%s is ignored when building with the " + "native 'arduino' toolchain", + key, + ) + + +_RAM_SECTIONS = (".data", ".rodata", ".bss") +_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata") + + +def get_build_dir() -> Path: + return CORE.relative_pioenvs_path(CORE.name) + + +def get_elf_path() -> Path: + return get_build_dir() / "firmware.elf" + + +def _toolchain_tool(name: str) -> Path: + # Imported here, not at module scope: the serial upload/logs fast path + # resolves this module for its artifact paths alone, and framework + # pulls in the whole package-download stack + from esphome.arduino8266 import framework + + return framework.toolchain_tool(framework.get_toolchain_path(), name) + + +def get_factory_firmware_path() -> Path: + """The image to serial-flash at 0x0 (same bytes as firmware.bin: the + 8266 factory copy exists for artifact-contract parity, not content).""" + return get_build_dir() / "firmware.factory.bin" + + +def get_addr2line_path() -> Path: + return _toolchain_tool("addr2line") + + +def get_objdump_path() -> Path: + return _toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return _toolchain_tool("readelf") + + +def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.arduino8266 import framework + from esphome.build_gen import arduino8266 as build_gen + + _warn_ignored_platformio_options() + paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) + # Resolved once: the probe is not free and three consumers need it + ccache = resolve_ccache_path() + build_gen.write_project(paths, ccache) + + build_dir = get_build_dir() + env = framework.get_build_env(paths.toolchain, ccache) + + # The DB is a pure function of build.ninja; regenerate when it is older + compdb = build_dir / "compile_commands.json" + ninja_file = build_dir / "build.ninja" + if not compdb.is_file() or compdb.stat().st_mtime < ninja_file.stat().st_mtime: + _write_compile_commands(paths.ninja, build_dir, env) + + cmd = [str(paths.ninja)] + if verbose: + cmd.append("-v") + if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT): + cmd += ["-j", str(jobs)] + # Explicit targets: a generator defect that drops them fails loudly + # instead of a green no-op run leaving stale artifacts in place + targets = ["firmware.factory.bin", "firmware.ota.bin"] + cmd += targets + + # cwd, not -C: drops ninja's "Entering directory" banner + _LOGGER.debug("Running: %s", " ".join(cmd)) + rc = subprocess.run( + cmd, cwd=build_dir, env=env, check=False, close_fds=False + ).returncode + if rc != 0: + return rc + + # ninja already refused missing targets; existence covers a rule that + # ran but wrote elsewhere + build_dir_artifacts = ( + get_elf_path(), + build_dir / "firmware.bin", + get_factory_firmware_path(), + build_dir / "firmware.ota.bin", + ) + for artifact in build_dir_artifacts: + if not artifact.is_file(): + _LOGGER.error("Build produced no %s", artifact) + return 1 + + if not _print_size_summary(build_dir, paths): + # Cause already warned; name the consequence for CI harnesses + _LOGGER.warning("Firmware size summary unavailable for this build") + from esphome.build_helpers.idedata import warn_if_idedata_missing + + warn_if_idedata_missing(lambda: get_idedata(ccache)) + return 0 + + +def _write_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str] +) -> None: + compdb = build_dir / "compile_commands.json" + result = subprocess.run( + [str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"], + env=env, + capture_output=True, + text=True, + check=False, + close_fds=False, + ) + if result.returncode != 0: + # Drop any stale database so consumers (IDE integration, clang-tidy, + # the memory analyzer) can't silently read outdated data. + compdb.unlink(missing_ok=True) + raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") + try: + entries = json.loads(result.stdout) + except ValueError as err: + compdb.unlink(missing_ok=True) + raise EsphomeError( + f"ninja produced an unparsable compile database: {err} " + f"(output starts {result.stdout[:120]!r})" + ) from err + if not entries: + # compdb exits 0 with [] for unknown rule names; a renamed compile + # rule must fail the build, not silently strand every consumer + compdb.unlink(missing_ok=True) + raise EsphomeError( + "ninja produced an empty compile database; the generator's rule " + "names no longer match" + ) + write_file(compdb, result.stdout) + + +def _parse_app_size(build_dir: Path, paths: InstalledPaths) -> int | None: + """Read the app flash budget (irom0_0_seg length) from the linker script.""" + from esphome.build_gen.arduino8266 import get_flash_ld_path + from esphome.components.esp8266.build_surgery import segment_length + + # Warnings, not debug: without the app size the Flash summary line is + # dropped and CI's memory-impact extraction loses its flash metric. + ld_path = get_flash_ld_path(build_dir, paths) + try: + ld_text = ld_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as err: + # A corrupt script degrades the same way, never aborts the build + _LOGGER.warning("Cannot read linker script for the Flash summary: %s", err) + return None + if not (app_size := segment_length(ld_text, "irom0_0_seg")): + _LOGGER.warning("No usable irom0_0_seg in %s; skipping Flash summary", ld_path) + return None + return app_size + + +def _print_size_summary(build_dir: Path, paths: InstalledPaths) -> bool: + """Print the RAM/Flash lines ``ci_memory_impact_extract.py`` parses; + False when skipped.""" + from esphome.arduino8266.framework import toolchain_tool + from esphome.build_helpers.size_summary import print_size_line + + try: + result = subprocess.run( + [ + str(toolchain_tool(paths.toolchain, "size")), + "-A", + "-d", + str(build_dir / "firmware.elf"), + ], + capture_output=True, + text=True, + check=True, + close_fds=False, + ) + except (OSError, subprocess.CalledProcessError) as err: + # The summary is a bonus artifact like idedata; a truncated + # toolchain extraction must not discard an already-linked build + _LOGGER.warning("Could not summarize firmware size: %s", err) + return False + # -d prints decimal sizes; anything else trips the missing-sections guard + sections = { + parts[0]: int(parts[1]) + for line in result.stdout.splitlines() + if (parts := line.split())[:1] and parts[0].startswith(".") and len(parts) >= 2 + if parts[1].isdigit() + } + if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections): + # A defaulted 0 would print a confidently wrong total for CI's metric + _LOGGER.warning( + "Size output is missing section(s) %s; skipping the size summary", + ", ".join(sorted(missing)), + ) + return False + # Resolve the flash budget before printing: a RAM line without its + # Flash line would skew CI's memory-impact extraction + app_size = _parse_app_size(build_dir, paths) + if not app_size: + return False + ram = sum(sections[s] for s in _RAM_SECTIONS) + flash = sum(sections[s] for s in _FLASH_SECTIONS) + print_size_line("RAM", ram, _MAX_RAM_SIZE) + print_size_line("Flash", flash, app_size) + return True + + +def get_idedata(ccache: str | None = None) -> dict | None: + """Derive idedata from the build's compile_commands.json (same + contract as ``espidf.toolchain.get_idedata``).""" + from esphome.build_helpers.idedata import load_or_build_idedata + + # A disabled ccache resolves to None without spawning anything, so + # re-resolving here costs nothing when the caller has no answer + launcher = ccache or resolve_ccache_path() + return load_or_build_idedata( + get_build_dir() / "compile_commands.json", + get_elf_path(), + # Suffixed so a platformio->arduino->platformio round trip on one + # config never serves the other toolchain's cache shape + CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"), + # The compile DB's commands carry the same ccache prefix the ninja + # rules were generated with + launcher=str(launcher) if launcher else None, + ) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index b6ac88f1234..285379ceea2 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1132,7 +1132,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: "rule elf2bin", # --flash_size deliberately stays board-derived, as under # PlatformIO (which reads upload.maximum_size, not the ldscript). - f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + # -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+. + f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", " description = BIN $out", "rule copy", " command = $python $buildtool copy $in $out", diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 038fe649703..98ee8044030 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -11,6 +11,7 @@ consumers (IDE integration, clang-tidy) expect: from __future__ import annotations +from collections.abc import Callable import json import logging import os @@ -21,6 +22,8 @@ import subprocess from esphome.core import EsphomeError from esphome.helpers import write_file +_LOGGER = logging.getLogger(__name__) + # Everything idedata generation may raise after a successful link; idedata # is a bonus artifact, so consumers warn instead of failing the build IDEDATA_BEST_EFFORT_ERRORS = ( @@ -31,7 +34,21 @@ IDEDATA_BEST_EFFORT_ERRORS = ( ValueError, ) -_LOGGER = logging.getLogger(__name__) + +def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None: + """Run an idedata generator, downgrading any failure to a warning: + the firmware already built.""" + try: + if get_idedata() is None: + _LOGGER.warning("No idedata was generated for this build") + except IDEDATA_BEST_EFFORT_ERRORS as err: + _LOGGER.warning( + "Could not generate idedata: %s (IDE, clang-tidy, and " + "memory-analysis data will be unavailable for this build)", + err, + ) + _LOGGER.debug("Idedata failure detail", exc_info=True) + # C++ translation-unit suffixes used to identify ESPHome source files. _CXX_SUFFIXES = (".cpp", ".cc") diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py new file mode 100644 index 00000000000..cde526b8033 --- /dev/null +++ b/esphome/build_helpers/native.py @@ -0,0 +1,34 @@ +"""Resolution of the native (non-PlatformIO) build backend for a config. + +Kept deliberately light: the serial upload and logs fast path resolves a +backend for its artifact paths alone, so importing this must not pull in a +platform component package or the backend itself. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +from esphome.const import Toolchain +from esphome.core import CORE, EsphomeError + +# Native build backend per (target platform, toolchain) +NATIVE_TOOLCHAIN_MODULES = { + ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", + ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", +} + + +def native_backend() -> ModuleType | None: + """The native build backend module for the resolved toolchain.""" + if not CORE.using_native_toolchain: + return None + key = (CORE.target_platform, CORE.toolchain) + if (module_path := NATIVE_TOOLCHAIN_MODULES.get(key)) is None: + # Degrading to the PlatformIO path would build with the wrong backend + raise EsphomeError( + f"Toolchain '{CORE.toolchain.value}' has no native build backend " + f"module for platform {CORE.target_platform}" + ) + return importlib.import_module(module_path) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 56509b9fabb..808d7ca7a9d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -5,6 +5,7 @@ import re import subprocess from typing import Any +from esphome.build_helpers.native import native_backend import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -14,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_TOOLCHAIN, CONF_VERSION, KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -21,6 +23,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_ESP8266, ThreadModel, + Toolchain, ) from esphome.core import ( CORE, @@ -43,6 +46,7 @@ from .const import ( CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, KEY_BOARD, + KEY_DECODE_TOOLS, KEY_ESP8266, KEY_PIN_INITIAL_STATES, KEY_SCANF_FLOAT, @@ -105,6 +109,47 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO) + + +def _validate_native_toolchain(config: ConfigType) -> ConfigType: + """Constraints of the native (non-PlatformIO) Arduino toolchain.""" + if not CORE.using_toolchain_arduino: + return config + from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION + + conf = config[CONF_FRAMEWORK] + version = cv.Version.parse(conf[CONF_VERSION]) + if version < MIN_FRAMEWORK_VERSION: + raise cv.Invalid( + "'toolchain: arduino' requires framework version " + f"{MIN_FRAMEWORK_VERSION} or newer" + ) + # platform_version is a PlatformIO concept; drop it, warning when a + # custom pin is discarded + if ( + conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC) + != _ARDUINO_4_PLATFORM_SPEC + ): + _LOGGER.warning( + "'platform_version' is ignored by 'toolchain: arduino'; the native " + "toolchain downloads the framework and compiler directly" + ) + if conf[CONF_SOURCE] != _format_framework_arduino_version(version): + raise cv.Invalid( + "'toolchain: arduino' does not support a custom framework source; " + "use 'toolchain: platformio'" + ) + if config[CONF_BOARD] not in BOARDS: + raise cv.Invalid( + f"Board '{config[CONF_BOARD]}' is not supported by " + "'toolchain: arduino'; use 'toolchain: platformio'" + ) + return config + + def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built ESP8266 firmware. @@ -134,7 +179,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 # Same encoding the native toolchain uses for its package download, so a - # version bump cannot drift between the two paths. + # custom-source check against this value cannot drift from what it fetches. from esphome.arduino8266.framework import framework_package_version try: @@ -196,7 +241,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: platform_version = value.get(CONF_PLATFORM_VERSION) if platform_version is None: if version >= cv.Version(3, 1, 0): - platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) + platform_version = _ARDUINO_4_PLATFORM_SPEC else: platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version @@ -219,6 +264,10 @@ def _parse_platform_version(value: Any) -> str: return value +# The platform_version derived for every core >= 3.1.0 config +_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) + + ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { @@ -251,11 +300,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, } ), - # Until the native toolchain lands, PlatformIO is the only backend; - # reject a --toolchain this platform cannot serve yet. - cv.require_platformio_toolchain("ESP8266"), + _resolve_toolchain, + _validate_native_toolchain, set_core_data, ) @@ -295,12 +346,13 @@ def _choose_ld_script(board: str) -> str: @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: + use_platformio = CORE.using_toolchain_platformio cg.add(esp8266_ns.setup_preferences()) - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - - cg.add_platformio_option("board", config[CONF_BOARD]) + if use_platformio: + cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_ESP8266") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -318,29 +370,30 @@ async def to_code(config: ConfigType) -> None: # The native generator reads the same decision (KEY_SCANF_FLOAT) CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) - - extra_scripts = [ - "pre:ccache.py", - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:relocate_ratetable.py", - ] - if not enable_scanf_float: - extra_scripts.append("pre:remove_float_scanf.py") - extra_scripts.append("post:post_build.py") - cg.add_platformio_option("extra_scripts", extra_scripts) + if use_platformio: + extra_scripts = [ + "pre:ccache.py", + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] - cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO") cg.add_build_flag("-Wno-nonnull-compare") - cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) - cg.add_platformio_option( - "platform_packages", - [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], - ) + if use_platformio: + cg.add_platformio_option("framework", "arduino") + cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) + cg.add_platformio_option( + "platform_packages", + [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], + ) # Default for platformio is LWIP2_LOW_MEMORY with: # - MSS=536 @@ -369,8 +422,8 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h for details. Must be prepended before , so this - # uses build_src_flags with -include. + # See throw_stubs.h. Unconditional: the native build generator reads + # the same option, keeping one source of truth. cg.add_platformio_option( "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" ) @@ -400,6 +453,8 @@ async def to_code(config: ConfigType) -> None: # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") + # Unconditional: the native build generator reads the same option, + # keeping one source of truth cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -408,7 +463,7 @@ async def to_code(config: ConfigType) -> None: cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - if config[CONF_BOARD] in BOARDS: + if use_platformio and config[CONF_BOARD] in BOARDS: cg.add_platformio_option( "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) ) @@ -449,8 +504,24 @@ async def finalize_serial_config() -> None: cg.add_build_flag("-DNO_GLOBAL_SERIAL1") +# Called by __main__.compile_program; returning False falls through to the +# PlatformIO toolchain. +def run_compile(args, config: ConfigType) -> bool: + # Positive check: the native backend only runs when explicitly resolved + toolchain = native_backend() + if toolchain is None: + return False + if toolchain.run_compile(config, CORE.verbose) != 0: + raise EsphomeError("ESP8266 native build failed") + return True + + # Called by writer.py def copy_files() -> None: + # Native builds skip the PlatformIO extra scripts; the build generator + # carries their logic + if CORE.using_toolchain_arduino: + return dir = Path(__file__).parent for script in ( "post_build", @@ -507,22 +578,54 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config: ConfigType, addr: str) -> None: +def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: + """``(addr2line, elf)`` for this build, or None after warning why.""" + if (native_toolchain := native_backend()) is not None: + addr2line = native_toolchain.get_addr2line_path() + elf = native_toolchain.get_elf_path() + for path in (addr2line, elf): + if not path.is_file(): + _LOGGER.warning("Cannot decode crash addresses: %s missing", path) + return None + return str(addr2line), str(elf) from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: - _LOGGER.debug("decode_pc no addr2line") + _LOGGER.warning("Cannot decode crash addresses: no addr2line or ELF in idedata") + return None + return idedata.addr2line_path, idedata.firmware_elf_path + + +def _decode_tools(config: ConfigType) -> tuple[str, str] | None: + """Resolved once per run: the tools are a property of the build, not of + the address, so a stack dump cannot repeat the failure warning.""" + data = CORE.data.setdefault(KEY_ESP8266, {}) + if KEY_DECODE_TOOLS not in data: + data[KEY_DECODE_TOOLS] = _resolve_decode_tools(config) + return data[KEY_DECODE_TOOLS] + + +def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None: + """Decode one crash address. ``bulk``: the caller is scanning every + 8-hex stack word, most of which are not code addresses -- unmappable + ones log at debug so real frames are not buried.""" + # Bulk stack words are ~150 per dump; only named registers warn + log = _LOGGER.debug if bulk else _LOGGER.warning + if (tools := _decode_tools(config)) is None: + log("Not decoded %s (no addr2line or ELF)", addr) return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + addr2line, elf = tools + command = [addr2line, "-pfiaC", "-e", elf, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + log("Could not decode crash address %s (%s)", addr, err) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return if "?? ??:0" in translation: - # Nothing useful + log("Not decoded %s (address not in %s)", addr, elf) return translation = translation.replace(" at ??:?", "").replace(":?", "") _LOGGER.warning("Decoded %s", translation) @@ -592,6 +695,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> if backtrace_state: for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line): - _decode_pc(config, addr.group()) + _decode_pc(config, addr.group(), bulk=True) return backtrace_state diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index ac2fe0aa3b4..bf4dd1a3584 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -19,6 +19,8 @@ KEY_SERIAL1_REQUIRED = "serial1_required" KEY_SCANF_FLOAT = "scanf_float" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" +# Crash-decode tools, resolved once per run by __init__ +KEY_DECODE_TOOLS = "decode_tools" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/core/config.py b/esphome/core/config.py index 4d3fb833124..bb3f03d3663 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -555,7 +555,13 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri # that is stored rather than translated away. Consumed by the esp8266 native # backend (later in this chain) for its ignored-option warning; defined here # so it stays adjacent to the routing. -NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"} +# build_src_flags and board_build.flash_mode are not user-routable, so +# not in the set above +NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | { + "lib_ignore", + "build_src_flags", + "board_build.flash_mode", +} @coroutine_with_priority(CoroPriority.FINAL) diff --git a/esphome/writer.py b/esphome/writer.py index 9ab5d644c51..bddb6a0f5f8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -609,10 +609,10 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) - # The idedata cache is derived from the build but lives under the data dir, - # not the build path, so it must be removed separately in both modes. - idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") - if idedata_cache.is_file(): + # idedata caches live under the data dir, not the build path; globbed + # so a future backend suffix cannot drift out of clean-all + idedata_dir = CORE.relative_internal_path("idedata") + for idedata_cache in idedata_dir.glob(f"{CORE.name}*.json"): _LOGGER.info("Deleting %s", idedata_cache) idedata_cache.unlink() diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py new file mode 100644 index 00000000000..951217c8a10 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -0,0 +1,197 @@ +"""Tests for the native (non-PlatformIO) toolchain config validation.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.components import esp8266 +from esphome.components.esp8266 import ( + ARDUINO_FRAMEWORK_SCHEMA, + _resolve_toolchain, + _validate_native_toolchain, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_BOARD, + CONF_FRAMEWORK, + CONF_PLATFORM_VERSION, + CONF_SOURCE, + CONF_TOOLCHAIN, + CONF_VERSION, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP8266, + Toolchain, +) +from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType + + +@pytest.fixture(autouse=True) +def _arduino_toolchain() -> Generator[None]: + # The suite-wide reset_core fixture clears both after each test; the + # shared backend resolver reads the platform as well as the toolchain, + # and the decode-tool cache lives in CORE.data + CORE.toolchain = Toolchain.ARDUINO + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 + yield + + +def _config( + board: str = "nodemcuv2", + version: str = "3.1.2", + source: str | None = None, + platform_version: str | None = None, +) -> ConfigType: + framework: dict[str, str] = {CONF_VERSION: version} + if source is not None: + framework[CONF_SOURCE] = source + if platform_version is not None: + framework[CONF_PLATFORM_VERSION] = platform_version + # The real schema fills the source/platform_version defaults, so these + # tests validate against what config validation actually emits + return { + CONF_FRAMEWORK: ARDUINO_FRAMEWORK_SCHEMA(framework), + CONF_BOARD: board, + } + + +def test_valid_config_passes() -> None: + config = _config() + assert _validate_native_toolchain(config) is config + + +def test_platformio_toolchain_skips_checks() -> None: + # 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native + # toolchain's own 3.1.1 floor; the bogus board only the native path checks + CORE.toolchain = Toolchain.PLATFORMIO + config = _config(board="not_a_board", version="3.0.2") + assert _validate_native_toolchain(config) is config + + +def test_version_below_floor_rejected() -> None: + # 3.1.0 has no registry package, so the native floor is 3.1.1 + with pytest.raises(cv.Invalid, match="3.1.1 or newer"): + _validate_native_toolchain(_config(version="3.1.0")) + + +def test_version_at_floor_accepted() -> None: + _validate_native_toolchain(_config(version="3.1.1")) + + +def test_custom_platform_version_warns_and_is_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: + config = _config(platform_version="platformio/espressif8266@4.0.1") + _validate_native_toolchain(config) + assert "'platform_version' is ignored" in caplog.text + assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK] + + +def test_default_platform_version_does_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + config = _config() + _validate_native_toolchain(config) + assert "'platform_version' is ignored" not in caplog.text + assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK] + + +def test_custom_source_rejected() -> None: + with pytest.raises(cv.Invalid, match="custom framework source"): + _validate_native_toolchain( + _config(source="https://github.com/esp8266/Arduino.git") + ) + + +def test_unsupported_board_rejected() -> None: + with pytest.raises(cv.Invalid, match="not supported by"): + _validate_native_toolchain(_config(board="not_a_board")) + + +def test_yaml_toolchain_key_resolves() -> None: + """The documented `toolchain: arduino` YAML key selects the native path.""" + CORE.toolchain = None + _resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO}) + assert CORE.toolchain == Toolchain.ARDUINO + assert CORE.using_toolchain_arduino + + +def test_yaml_toolchain_key_defaults_to_platformio() -> None: + CORE.toolchain = None + _resolve_toolchain({}) + assert CORE.toolchain == Toolchain.PLATFORMIO + + +def test_decode_pc_native_missing_tools_warns_once( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stack dump of many addresses produces one missing-tool warning.""" + + with ( + patch( + "esphome.arduino8266.toolchain.get_addr2line_path", + return_value=tmp_path / "missing-addr2line", + ), + patch( + "esphome.arduino8266.toolchain.get_elf_path", + return_value=tmp_path / "missing.elf", + ), + ): + esp8266._decode_pc({}, "40201234") + esp8266._decode_pc({}, "40201238") + assert caplog.text.count("Cannot decode crash addresses") == 1 + + +def test_decode_pc_platformio_missing_tools_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The PlatformIO branch reports a missing addr2line/ELF at the same + warning level as the native one; raw undecoded addresses with no + stated reason are undiagnosable at default log level.""" + + CORE.toolchain = Toolchain.PLATFORMIO + idedata = SimpleNamespace(addr2line_path=None, firmware_elf_path=None) + with patch("esphome.platformio.toolchain.get_idedata", return_value=idedata): + esp8266._decode_pc({}, "40201234") + esp8266._decode_pc({}, "40201238") + assert caplog.text.count("Cannot decode crash addresses") == 1 + + +def test_resolve_toolchain_rejects_unsupported() -> None: + """ESP8266 rejects a CLI toolchain it cannot serve, like every platform.""" + + CORE.toolchain = Toolchain.SDK_NRF + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"): + _resolve_toolchain({}) + + +def test_run_compile_platformio_falls_through() -> None: + """Under toolchain: platformio the hook returns False without touching + the native backend; this is what keeps existing users on PlatformIO.""" + CORE.toolchain = Toolchain.PLATFORMIO + with patch("esphome.arduino8266.toolchain.run_compile") as mock_native: + assert esp8266.run_compile(SimpleNamespace(), {}) is False + mock_native.assert_not_called() + + +def test_run_compile_arduino_failure_raises() -> None: + """A non-zero native build fails by name instead of returning success.""" + CORE.verbose = False + with ( + patch("esphome.arduino8266.toolchain.run_compile", return_value=1), + pytest.raises(EsphomeError, match="native build failed"), + ): + esp8266.run_compile(SimpleNamespace(), {}) + + +def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None: + """The native build writes no PlatformIO extra scripts.""" + CORE.build_path = tmp_path + esp8266.copy_files() + assert list(tmp_path.iterdir()) == [] diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 0db70396687..189c024b3a7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1480,12 +1480,3 @@ async def test_add_platformio_options_native_arduino( assert "board_build.ldscript is ignored" in caplog.text assert "'arduino' toolchain" in caplog.text assert "upload_speed" not in caplog.text - - -def test_esp8266_rejects_unsupported_cli_toolchain() -> None: - """Until the native backend lands, ESP8266 serves only PlatformIO.""" - from esphome.components.esp8266 import CONFIG_SCHEMA - - CORE.toolchain = Toolchain.ARDUINO - with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): - CONFIG_SCHEMA({"board": "nodemcuv2"}) diff --git a/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py new file mode 100644 index 00000000000..ee9751de80d --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py @@ -0,0 +1,65 @@ +"""Run the native-toolchain serial-upload path and report heavy modules. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +``upload_using_esptool`` dispatches native toolchains through a +toolchain-keyed table; if someone routes it back through the platform +component packages (esp32 or esp8266), this reports the leak. +""" + +import os +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch + +from _leak_report import print_leaked_modules + +from esphome.__main__ import upload_using_esptool +from esphome.const import ( + CONF_ESPHOME, + KEY_CORE, + KEY_ESP32, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + Toolchain, +) +from esphome.core import CORE + +# An ambient ESPHOME_USE_SUBPROCESS would route past the patched +# run_external_command into run_external_process and confuse the checks. +os.environ.pop("ESPHOME_USE_SUBPROCESS", None) + +config = {CONF_ESPHOME: {"platformio_options": {}}} + +with tempfile.TemporaryDirectory() as build_dir: + CORE.name = "leaktest" + CORE.build_path = build_dir + + for platform, toolchain, backend in ( + ("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + ("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + ): + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + if platform == "esp32": + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + CORE.toolchain = toolchain + + import importlib + + image = importlib.import_module(backend).get_factory_firmware_path() + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"\x00") + + with patch("esphome.__main__.run_external_command", return_value=0) as mock_run: + rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + # Fail loudly if the upload path stopped doing its work; otherwise + # an empty leak list could just mean nothing ran. + if rc != 0: + sys.exit(f"upload_using_esptool({platform}) returned {rc}") + cmd = list(mock_run.call_args[0][1:]) + if str(image) not in [str(Path(c)) for c in cmd]: + sys.exit(f"native factory image did not reach esptool: {cmd}") + +print_leaked_modules() diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py new file mode 100644 index 00000000000..a9c0546b4b1 --- /dev/null +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -0,0 +1,548 @@ +"""Tests for esphome.arduino8266.toolchain (the ninja build driver).""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.arduino8266 import framework, toolchain +import esphome.config_validation as cv +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_FRAMEWORK_VERSION, +) +from esphome.core import CORE, EsphomeError + +_SIZE_OUTPUT = """\ +firmware.elf : +section size addr +.data 1924 1073643520 +.noinit 56 1073645444 +.text 496 1074790400 +.irom0.text 342804 1075843088 +.text1 27489 1074790896 +.rodata 2588 1073645504 +.bss 26504 1073648096 +Total 401861 +""" + + +@pytest.fixture(autouse=True) +def _setup_core(tmp_path: Path) -> None: + CORE.name = "test8266" + CORE.config_path = tmp_path / "test8266.yaml" + CORE.build_path = tmp_path + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)} + # run_compile verifies the produced artifacts; give every test a build + # that "produced" them (tests for the guard delete them again). The + # manifest comes first: artifacts must not be older than build.ninja. + build_dir = CORE.relative_pioenvs_path("test8266") + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "build.ninja").write_text("# manifest") + for artifact in ( + "firmware.elf", + "firmware.bin", + "firmware.factory.bin", + "firmware.ota.bin", + ): + (build_dir / artifact).write_bytes(b"") + + +def _paths(tmp_path: Path) -> framework.InstalledPaths: + return framework.InstalledPaths( + framework=tmp_path / "framework", + toolchain=tmp_path / "toolchain", + ninja=tmp_path / "ninja", + ) + + +def test_path_getters(tmp_path: Path) -> None: + assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266") + assert toolchain.get_elf_path().name == "firmware.elf" + # The framework accessor owns the layout and the Windows suffix + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}" + assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}" + assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}" + + +def test_run_compile_build_failure(tmp_path: Path) -> None: + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, "run", return_value=MagicMock(returncode=2) + ) as mock_run, + patch.object(toolchain, "_write_compile_commands") as mock_compdb, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2 + cmd = mock_run.call_args[0][0] + assert "-v" in cmd + # The compile database is generated before the build runs, so a failed + # build cannot leave a stale database behind. + mock_compdb.assert_called_once() + + +def test_run_compile_success(tmp_path: Path) -> None: + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=False), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ) as mock_run, + patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary") as mock_size, + patch.object(toolchain, "get_idedata") as mock_idedata, + ): + rc = toolchain.run_compile( + {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False + ) + assert rc == 0 + call = next(c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])) + cmd = call[0][0] + # Explicit targets: a manifest missing them fails as "unknown target" + assert {"firmware.factory.bin", "firmware.ota.bin"} <= set(cmd) + assert cmd[cmd.index("-j") + 1] == "4" + # cwd, not -C, so ninja prints no "Entering directory" banner + assert "-C" not in cmd + assert call[1]["cwd"] is not None + mock_compdb.assert_called_once() + mock_size.assert_called_once() + mock_idedata.assert_called_once() + + +def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: + """An interrupted run can leave build.ninja newer than the compile DB; + mere existence must not skip regeneration.""" + build_dir = toolchain.get_build_dir() + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "build.ninja").write_text("") + compdb = build_dir / "compile_commands.json" + compdb.write_text("[]") + os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2) + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=False), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock( + returncode=0, stdout="ninja: no work to do.\n", stderr="" + ), + ), + patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + mock_compdb.assert_called_once() + + +def test_run_compile_missing_artifact_fails( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A zero ninja exit that produced no firmware must not be a green + build (size summary and idedata only warn).""" + (toolchain.get_build_dir() / "firmware.elf").unlink() + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "_print_size_summary") as mock_size, + patch.object(toolchain, "get_idedata"), + ): + rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) + assert rc == 1 + assert "Build produced no" in caplog.text + mock_size.assert_not_called() + + +def test_run_compile_warns_when_idedata_fails( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed idedata generation right after a successful build is visible, + not deferred to a misleading error in a later command.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata", return_value=None), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "No idedata was generated for this build" in caplog.text + + +def test_write_compile_commands(tmp_path: Path) -> None: + build_dir = tmp_path / "build" + build_dir.mkdir() + entries = '[{"file": "a.cpp", "command": "cc"}]\n' + with patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=entries), + ): + toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) + assert (build_dir / "compile_commands.json").read_text() == entries + + +@pytest.mark.parametrize( + ("stdout", "match"), + [ + ("[]\n", "empty compile database"), + # A parse failure names its cause, not the rule-name story + ("not json", "unparsable compile database.*not json"), + ], +) +def test_write_compile_commands_bad_db_raises( + tmp_path: Path, stdout: str, match: str +) -> None: + """An empty or unparsable compile database fails the build with its + actual cause and drops any stale database.""" + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "compile_commands.json").write_text("[stale]") + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=stdout), + ), + pytest.raises(EsphomeError, match=match), + ): + toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) + assert not (build_dir / "compile_commands.json").exists() + + +def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None: + """A failed compdb run must not leave a stale database behind.""" + stale = tmp_path / "compile_commands.json" + stale.write_text("[]") + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=1, stderr="boom"), + ), + pytest.raises(EsphomeError, match="compile_commands"), + ): + toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {}) + assert not stale.exists() + + +def test_parse_app_size(tmp_path: Path) -> None: + ld = tmp_path / "eagle.flash.4m.ld" + ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) == 0xFEFF0 + + ld.write_text("MEMORY { }\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + # A zero-length segment is bad data, not a budget; warn and drop it + ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + with patch( + "esphome.build_gen.arduino8266.get_flash_ld_path", + return_value=tmp_path / "missing.ld", + ): + assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None + + +def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT), + ), + patch.object(toolchain, "_parse_app_size", return_value=1044464), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + out = capsys.readouterr().out + # Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it + assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out + assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out + + +def test_print_size_summary_missing_size_tool_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A missing size binary degrades to a warning; the firmware already + linked and must not be discarded.""" + with patch.object( + toolchain.subprocess, "run", side_effect=FileNotFoundError("no size") + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert "Could not summarize firmware size" in caplog.text + + +def test_print_size_summary_no_app_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT), + ), + patch.object(toolchain, "_parse_app_size", return_value=None), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + out = capsys.readouterr().out + # Both lines are skipped together: a RAM line without Flash would skew + # CI's memory-impact sums across builds + assert out == "" + + +def test_print_size_summary_size_tool_failure( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + with patch.object( + toolchain.subprocess, + "run", + side_effect=subprocess.CalledProcessError(1, "size", stderr="bad elf"), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "Could not summarize firmware size" in caplog.text + + +def test_get_idedata_delegates(tmp_path: Path) -> None: + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", + return_value={"cc_path": "x"}, + ) as mock_load, + patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"), + ): + assert toolchain.get_idedata() == {"cc_path": "x"} + compile_commands, elf, cache = mock_load.call_args[0] + assert compile_commands.name == "compile_commands.json" + assert elf.name == "firmware.elf" + assert cache.name == "test8266.arduino.json" + # The exact configured launcher string is passed for compile DB parsing + # (resolve_ccache_path returns a str, untouched on every platform) + assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache" + + +def test_get_idedata_no_ccache(tmp_path: Path) -> None: + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", return_value={} + ) as mock_load, + patch.object(toolchain, "resolve_ccache_path", return_value=None), + ): + toolchain.get_idedata() + assert mock_load.call_args.kwargs["launcher"] is None + + +def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None: + """An unchanged build.ninja means the compile DB is already current.""" + build_dir = toolchain.get_build_dir() + build_dir.mkdir(parents=True, exist_ok=True) + # write_project (stubbed below) always leaves a build.ninja behind + (build_dir / "build.ninja").write_text("# manifest") + + def run(regenerate_expected: bool) -> None: + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=False), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert mock_compdb.called == regenerate_expected + + # Missing compile DB: regenerated even though build.ninja is unchanged + run(regenerate_expected=True) + # Present compile DB + unchanged build.ninja: skipped + (build_dir / "compile_commands.json").write_text("[]") + run(regenerate_expected=False) + + +def test_print_size_summary_unparsable_section( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A totals-relevant section that fails to parse must not produce a + confident wrong number; an irrelevant one is ignored.""" + bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc") + with patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=bad), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "missing section(s) .bss" in caplog.text + + caplog.clear() + harmless = _SIZE_OUTPUT + ".broken abc 0\n" + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=harmless), + ), + patch.object(toolchain, "_parse_app_size", return_value=1044464), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert "RAM:" in capsys.readouterr().out + assert not caplog.text + + +def test_print_size_summary_missing_section_skips_summary( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A totals section absent from the output must not default to zero.""" + without_bss = "\n".join( + line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line + ) + with patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout=without_bss), + ): + toolchain._print_size_summary(tmp_path, _paths(tmp_path)) + assert capsys.readouterr().out == "" + assert "missing section(s) .bss" in caplog.text + + +def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: + """Component-added options the native build drops are warned by name; + the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, + flash_mode) stay quiet.""" + CORE.platformio_options = { + "board_build.ldscript": "eagle.flash.4m2m.ld", + "board_build.f_cpu": "160000000L", + "board_build.filesystem": "littlefs", + "board_build.flash_mode": "dio", + "build_src_flags": "-include throw_stubs.h", + "lib_ignore": ["Updater"], + "upload_speed": "460800", + } + toolchain._warn_ignored_platformio_options() + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert "board_build.f_cpu is ignored" not in caplog.text + assert "lib_ignore" not in caplog.text + assert "build_src_flags" not in caplog.text + assert "flash_mode" not in caplog.text + # Component-added upload_speed never gets read under the native + # toolchain, so it must warn + assert "upload_speed" in caplog.text + + +def test_run_compile_idedata_error_does_not_fail_build( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unusable compile DB after a successful build warns, never fails.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "_print_size_summary"), + patch.object( + toolchain, + "get_idedata", + side_effect=EsphomeError("compile database is unusable"), + ), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "Could not generate idedata: compile database is unusable" in caplog.text + + +def test_run_compile_skipped_size_summary_names_consequence( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A skipped RAM/Flash summary warns about the missing metric; the + per-cause warnings alone are invisible to CI harnesses.""" + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project"), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "_print_size_summary", return_value=False), + patch.object(toolchain, "get_idedata", return_value=None), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "Firmware size summary unavailable for this build" in caplog.text + + +def test_parse_app_size_non_utf8_ld_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A corrupt (non-UTF-8) linker script degrades to the same warning as + an unreadable one, never a traceback after a successful link.""" + paths = _paths(tmp_path) + ld = tmp_path / "corrupt.ld" + ld.write_bytes(b"\xff\xfe not utf8") + with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld): + assert toolchain._parse_app_size(tmp_path, paths) is None + assert "Cannot read linker script" in caplog.text + + +def test_get_idedata_accepts_preresolved_ccache() -> None: + """run_compile threads its resolved ccache through; the probe must not + run again.""" + with ( + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", + return_value={"ok": True}, + ) as mock_build, + patch.object(toolchain, "resolve_ccache_path") as mock_resolve, + ): + assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True} + mock_resolve.assert_not_called() + assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache" diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index b6878c33a20..1eeddd80eac 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -34,7 +34,12 @@ HEAVY_MODULES = ( # Everything the storage fast path must keep out of sys.modules; the # existence guard and the leak check must watch the same list. -FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ("esphome.components.esp32",) +FAST_PATH_HEAVY_MODULES = HEAVY_MODULES + ( + "esphome.components.esp32", + # A native backend module resolves artifact paths on this path; its + # framework half pulls in the whole package-download stack + "esphome.platformio.registry", +) # Heavy only for modules that must not know about the API transport; # in the existence guard so a rename can't silently no-op its check. @@ -175,6 +180,27 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules( ) +def test_native_upload_fast_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """The native-toolchain serial upload dispatches through a + toolchain-keyed backend table; resolving the flash image must not drag + in the esp32 or esp8266 component package or the validation stack. + """ + leaked = _leaked_from_fixture( + fixture_path, + probe_env, + "native_upload_fast_path.py", + extra=("esphome.components.esp8266",), + ) + assert not leaked, ( + f"native upload_using_esptool pulls in heavy modules: {leaked}. " + "The upload fast path skips validation; importing a platform " + "component package executes its codegen module by design." + ) + + def test_api_client_does_not_import_heavy_modules() -> None: """``esphome.api_client`` is on the logs fast path and must stay light. diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 956ba86502c..b5e7181269b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -13,7 +13,7 @@ import sys import time from types import SimpleNamespace from typing import Any, Self -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import pytest from pytest import CaptureFixture @@ -7212,7 +7212,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" - setup_core() + setup_core(platform=PLATFORM_ESP32) CORE.toolchain = Toolchain.ESP_IDF data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} @@ -7226,7 +7226,7 @@ def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: def test_command_idedata_esp_idf_no_build_errors() -> None: """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" - setup_core() + setup_core(platform=PLATFORM_ESP32) CORE.toolchain = Toolchain.ESP_IDF with patch("esphome.espidf.toolchain.get_idedata", return_value=None): @@ -7400,6 +7400,201 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails( assert not caplog.text +def test_upload_using_esptool_arduino_toolchain( + tmp_path: Path, + mock_run_external_command_main: Mock, +) -> None: + """The native ESP8266 Arduino toolchain flashes its factory image at + 0x0, resolved from the toolchain-keyed backend table (deliberately not + the platform hook: that import would break the upload fast path).""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = Toolchain.ARDUINO + from esphome.arduino8266 import toolchain as native + + factory = native.get_factory_firmware_path() + factory.parent.mkdir(parents=True, exist_ok=True) + factory.touch() + + config = {CONF_ESPHOME: {"platformio_options": {}}} + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + firmware_offset_idx = cmd_list.index("write-flash") + 4 + assert cmd_list[firmware_offset_idx] == "0x0" + assert cmd_list[firmware_offset_idx + 1] == str(factory) + + +@pytest.mark.parametrize( + ("toolchain", "pio_project_written"), + [ + # The native toolchain generates its project at compile time, so + # write_cpp_file must not write a platformio.ini; the default + # toolchain writes the PlatformIO project files. + (Toolchain.ARDUINO, False), + (None, True), + ], +) +def test_write_cpp_file_project_generation_follows_toolchain( + tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool +) -> None: + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = toolchain + + with ( + patch("esphome.writer.write_cpp") as mock_write_cpp, + patch("esphome.build_gen.platformio.write_project") as mock_pio_project, + patch.object( + type(CORE), "cpp_main_section", new_callable=PropertyMock + ) as mock_section, + ): + mock_section.return_value = "" + assert main.write_cpp_file() == 0 + + mock_write_cpp.assert_called_once() + assert mock_pio_project.called is pio_project_written + + +def test_command_idedata_arduino_prints_json( + tmp_path: Path, capsys: CaptureFixture +) -> None: + """Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch( + "esphome.arduino8266.toolchain.get_idedata", return_value=data + ) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: + """A missing native build (no idedata) returns an error, not a crash.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + + with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 + + +@pytest.mark.parametrize( + ("platform", "toolchain", "module"), + [ + (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + ], +) +def test_command_analyze_memory_native_toolchains( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, + platform: str, + toolchain: Toolchain, + module: str, +) -> None: + """analyze-memory uses the native toolchain's binutils instead of + falling into the PlatformIO branch.""" + setup_core(platform=platform, tmp_path=tmp_path, name="test_device") + CORE.toolchain = toolchain + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + # The tools must exist: a missing binutils now fails by name instead of + # silently falling back to host tools + objdump = tmp_path / "objdump" + readelf = tmp_path / "readelf" + objdump.write_text("") + readelf.write_text("") + # The ELF must exist too: the analyzer swallows tool failures, so a + # missing image would report zeroes with exit 0 + firmware_elf = tmp_path / "firmware.elf" + firmware_elf.write_text("") + with ( + patch(f"{module}.get_objdump_path", return_value=objdump), + patch(f"{module}.get_readelf_path", return_value=readelf), + patch(f"{module}.get_elf_path", return_value=firmware_elf), + ): + result = command_analyze_memory(MockArgs(), config) + + assert result == 0 + mock_memory_analyzer_cli.assert_called_once_with( + str(firmware_elf), + str(objdump), + str(readelf), + set(), + idedata=None, + ) + + +def test_command_analyze_memory_native_missing_elf_fails( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing firmware.elf fails by name instead of an exit-0 zeroed + report.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + objdump = tmp_path / "objdump" + readelf = tmp_path / "readelf" + objdump.write_text("") + readelf.write_text("") + module = "esphome.arduino8266.toolchain" + with ( + patch(f"{module}.get_objdump_path", return_value=objdump), + patch(f"{module}.get_readelf_path", return_value=readelf), + patch(f"{module}.get_elf_path", return_value=tmp_path / "missing.elf"), + ): + result = command_analyze_memory(MockArgs(), config) + + assert result == 1 + assert "compile the configuration first" in caplog.text + mock_memory_analyzer_cli.assert_not_called() + + +def test_command_analyze_memory_missing_binutils_fails_by_name( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A truncated toolchain install fails naming the missing tool instead + of silently analyzing with host binutils.""" + setup_core(platform="esp8266", tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + module = "esphome.arduino8266.toolchain" + with ( + patch(f"{module}.get_objdump_path", return_value=tmp_path / "missing-objdump"), + patch(f"{module}.get_readelf_path", return_value=tmp_path / "readelf"), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + ): + assert command_analyze_memory(MockArgs(), config) == 1 + assert "missing-objdump" in caplog.text + assert "toolchain install may be incomplete" in caplog.text + + +def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None: + """A non-native, non-platformio toolchain errors out cleanly.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + CORE.toolchain = Toolchain.SDK_NRF + + assert command_idedata(MagicMock(), CORE.config) == 1 + + @pytest.mark.parametrize( "error", [ @@ -7476,6 +7671,49 @@ def test_compile_program_espidf_idedata_none_warns( assert "No idedata was generated" in caplog.text +def test_native_toolchain_table_serves_every_native_toolchain() -> None: + """Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would + surface as a targeted EsphomeError on the one affected config, and this + pin keeps the table from drifting when a toolchain is added.""" + from esphome.build_helpers.native import NATIVE_TOOLCHAIN_MODULES + from esphome.const import NATIVE_TOOLCHAINS + + assert {tc for _, tc in NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS) + + +def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None: + """A native toolchain missing from the backend table is a bug and must + fail, not silently degrade to the PlatformIO path.""" + from esphome.build_helpers import native + + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + with ( + patch.dict(native.NATIVE_TOOLCHAIN_MODULES, clear=True), + pytest.raises(EsphomeError, match="no native build backend"), + ): + native.native_backend() + + +def test_command_analyze_memory_unsupported_toolchain( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook-less non-PlatformIO toolchain is refused by name, never routed + into the PlatformIO branch.""" + setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.SDK_NRF + mock_write_cpp.return_value = 0 + mock_compile_program.return_value = 0 + + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + + assert result == 1 + assert "analyze-memory is not supported" in caplog.text + + def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: """An explicit --toolchain must run the per-platform validators, so the upload/logs fast path becomes a cache miss.""" @@ -7491,6 +7729,29 @@ def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: mock_read.assert_called_once() +def test_upload_using_esptool_native_missing_firmware_raises( + tmp_path: Path, +) -> None: + """A stale or absent firmware.bin fails by name instead of flashing air.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test") + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(EsphomeError, match="compile the configuration first"): + upload_using_esptool( + {CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", None, None + ) + + +def test_compile_program_unclaimed_native_toolchain_raises( + tmp_path: Path, +) -> None: + """A resolved native toolchain no platform backend claims must fail, + never fall through to the PlatformIO project path.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend + with pytest.raises(EsphomeError, match="no platform backend claimed"): + compile_program(MockArgs(), {}) + + def test_cli_toolchain_still_refreshes_the_validated_config_cache( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 47feae3e3c0..3dcc4b12b88 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -516,10 +516,13 @@ def test_clean_build( dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") - # idedata cache lives under the data dir, not the build path. + # idedata caches live under the data dir, not the build path; the + # .arduino.json variant is the native esp8266 toolchain's. idedata_cache = tmp_path / "idedata" / "test.json" idedata_cache.parent.mkdir() idedata_cache.write_text("{}") + arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json" + arduino_idedata_cache.write_text("{}") # Native ESP-IDF toolchain artifacts. idf_build_dir = tmp_path / "build" @@ -580,6 +583,7 @@ def test_clean_build( assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() assert not idedata_cache.exists() + assert not arduino_idedata_cache.exists() assert not idf_build_dir.exists() assert not managed_components_dir.exists() assert not pio_components_dir.exists() From 6c5010032b1adaa186d164a78451ae9d5f6b88ab Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 26 Sep 2026 14:44:48 -0500 Subject: [PATCH 580/759] [pn532] Check InDataExchange status and fix tag handling (#19591) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/components/pn532/pn532.cpp | 104 ++++++++----- esphome/components/pn532/pn532.h | 60 ++++--- .../components/pn532/pn532_mifare_classic.cpp | 50 +++--- .../pn532/pn532_mifare_ultralight.cpp | 37 ++--- esphome/components/pn532_i2c/pn532_i2c.cpp | 13 +- esphome/components/pn532_spi/pn532_spi.cpp | 3 +- tests/components/pn532/pn532_test.cpp | 147 ++++++++++++++++++ 7 files changed, 301 insertions(+), 113 deletions(-) create mode 100644 tests/components/pn532/pn532_test.cpp diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 8ef77217262..17ee11d7d94 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -1,6 +1,7 @@ #include "pn532.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -25,7 +26,8 @@ void PN532::setup() { } std::vector version_data; - if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data)) { + // GetFirmwareVersion returns IC, Ver, Rev and Support + if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { ESP_LOGE(TAG, "Error getting version"); this->mark_failed(); return; @@ -35,8 +37,8 @@ void PN532::setup() { if (!this->write_command_({ PN532_COMMAND_SAMCONFIGURATION, 0x01, // normal mode - 0x14, // zero timeout (not in virtual card mode) - 0x01, + 0x14, // timeout: 20 x 50 ms (only used in virtual card mode) + 0x01, // use IRQ })) { ESP_LOGE(TAG, "No wakeup ack"); this->mark_failed(); @@ -90,8 +92,8 @@ bool PN532::powerdown() { ESP_LOGE(TAG, "Error reading PN532 powerdown response"); return false; } - if (response[0] != 0x00) { - ESP_LOGE(TAG, "Error on PN532 powerdown: %02x", response[0]); + if (response.empty() || response[0] != 0x00) { + ESP_LOGE(TAG, "Powerdown error: %02x", response.empty() ? 0xFF : response[0]); return false; } ESP_LOGV(TAG, "Powerdown successful"); @@ -150,7 +152,7 @@ void PN532::loop() { return; } - uint8_t num_targets = read[0]; + uint8_t num_targets = read.empty() ? 0 : read[0]; if (num_targets != 1) { // no tags found or too many if (!this->current_uid_.empty()) { @@ -163,12 +165,20 @@ void PN532::loop() { return; } + // target data for 106 kbps type A: NbTg, Tg, SENS_RES (2 bytes), SEL_RES, NFCIDLength, NFCID1 (UM0701-02, 7.3.5) + if (read.size() < 6) { + this->turn_off_rf_(); + return; + } + const uint8_t sel_res = read[4]; uint8_t nfcid_length = read[5]; - if (nfcid_length > nfc::NFC_UID_MAX_LENGTH || read.size() < 6U + nfcid_length) { + if (nfcid_length == 0 || nfcid_length > nfc::NFC_UID_MAX_LENGTH || read.size() < 6U + nfcid_length) { // oops, pn532 returned invalid data + this->turn_off_rf_(); return; } nfc::NfcTagUid nfcid(read.begin() + 6, read.begin() + 6 + nfcid_length); + const uint8_t tag_type = tag_type_from_sel_res(sel_res); bool report = true; for (auto *bin_sens : this->binary_sensors_) { @@ -188,7 +198,7 @@ void PN532::loop() { this->current_uid_ = nfcid; if (next_task_ == READ) { - auto tag = this->read_tag_(nfcid); + auto tag = this->read_tag_(nfcid, tag_type); for (auto *trigger : this->triggers_ontag_) trigger->process(tag); @@ -206,13 +216,13 @@ void PN532::loop() { } } else if (next_task_ == CLEAN) { ESP_LOGD(TAG, " Tag cleaning"); - if (!this->clean_tag_(nfcid)) { + if (!this->clean_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, " Tag was not fully cleaned successfully"); } ESP_LOGD(TAG, " Tag cleaned!"); } else if (next_task_ == FORMAT) { ESP_LOGD(TAG, " Tag formatting"); - if (!this->format_tag_(nfcid)) { + if (!this->format_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, "Error formatting tag as NDEF"); } ESP_LOGD(TAG, " Tag formatted!"); @@ -220,16 +230,15 @@ void PN532::loop() { if (this->next_task_message_to_write_ != nullptr) { ESP_LOGD(TAG, " Tag writing"); ESP_LOGD(TAG, " Tag formatting"); - if (!this->format_tag_(nfcid)) { + if (!this->format_tag_(nfcid, tag_type)) { ESP_LOGE(TAG, " Tag could not be formatted for writing"); } else { ESP_LOGD(TAG, " Writing NDEF data"); - if (!this->write_tag_(nfcid, this->next_task_message_to_write_)) { + if (!this->write_tag_(nfcid, tag_type, this->next_task_message_to_write_.get())) { ESP_LOGE(TAG, " Failed to write message to tag"); } ESP_LOGD(TAG, " Finished writing NDEF data"); - delete this->next_task_message_to_write_; - this->next_task_message_to_write_ = nullptr; + this->next_task_message_to_write_.reset(); this->on_finished_write_callback_.call(); } } @@ -307,16 +316,17 @@ void PN532::send_nack_() { enum PN532ReadReady PN532::read_ready_(bool block) { if (this->rd_ready_ == READY) { if (block) { - this->rd_start_time_.reset(); + this->rd_started_ = false; this->rd_ready_ = WOULDBLOCK; } return READY; } - if (!this->rd_start_time_.has_value()) { + if (!this->rd_started_) { this->rd_start_time_ = millis(); + this->rd_started_ = true; } - const uint32_t rd_start_time = *this->rd_start_time_; + const uint32_t rd_start_time = this->rd_start_time_; while (true) { if (this->is_read_ready()) { @@ -340,7 +350,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { auto rdy = this->rd_ready_; if (block || rdy == TIMEOUT) { - this->rd_start_time_.reset(); + this->rd_started_ = false; this->rd_ready_ = WOULDBLOCK; } return rdy; @@ -355,21 +365,16 @@ void PN532::turn_off_rf_() { }); } -std::unique_ptr PN532::read_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +std::unique_ptr PN532::read_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { ESP_LOGD(TAG, "Mifare classic"); return this->read_mifare_classic_tag_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { ESP_LOGD(TAG, "Mifare ultralight"); return this->read_mifare_ultralight_tag_(uid); - } else if (type == nfc::TAG_TYPE_UNKNOWN) { - ESP_LOGV(TAG, "Cannot determine tag type"); - return make_unique(uid); - } else { - return make_unique(uid); } + ESP_LOGV(TAG, "Reading tag type %u is not supported", tag_type); + return make_unique(uid); } void PN532::read_mode() { @@ -386,43 +391,58 @@ void PN532::format_mode() { } void PN532::write_mode(nfc::NdefMessage *message) { this->next_task_ = WRITE; - this->next_task_message_to_write_ = message; + this->next_task_message_to_write_.reset(message); ESP_LOGD(TAG, "Waiting to write next tag"); } -bool PN532::clean_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::clean_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->format_mifare_classic_mifare_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->clean_mifare_ultralight_(); } ESP_LOGE(TAG, "Unsupported Tag for formatting"); return false; } -bool PN532::format_tag_(nfc::NfcTagUid &uid) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::format_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->format_mifare_classic_ndef_(uid); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->clean_mifare_ultralight_(); } ESP_LOGE(TAG, "Unsupported Tag for formatting"); return false; } -bool PN532::write_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { - uint8_t type = nfc::guess_tag_type(uid.size()); - if (type == nfc::TAG_TYPE_MIFARE_CLASSIC) { +bool PN532::write_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type, nfc::NdefMessage *message) { + if (tag_type == nfc::TAG_TYPE_MIFARE_CLASSIC) { return this->write_mifare_classic_tag_(uid, message); - } else if (type == nfc::TAG_TYPE_2) { + } else if (tag_type == nfc::TAG_TYPE_2) { return this->write_mifare_ultralight_tag_(uid, message); } - ESP_LOGE(TAG, "Unsupported Tag for formatting"); + ESP_LOGE(TAG, "Unsupported Tag for writing"); return false; } +bool PN532::in_data_exchange_(const std::vector &command, std::vector &response) { + // formatting a tag takes seconds of back-to-back exchanges inside loop(), longer than the task watchdog allows + App.feed_wdt(); + if (!this->write_command_(command)) { + return false; + } + // output: Status, DataIn; a status of 0x00 means the exchange with the target succeeded (UM0701-02, 7.3.8) + if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response.empty()) { + return false; + } + if (response[0] != 0x00) { + ESP_LOGV(TAG, "InDataExchange failed, status 0x%02X", response[0]); + return false; + } + response.erase(response.begin()); + return true; +} + void PN532::dump_config() { ESP_LOGCONFIG(TAG, "PN532:"); switch (this->error_code_) { diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 629a697aa59..c518afc4914 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -19,12 +19,28 @@ static const uint8_t PN532_COMMAND_INDATAEXCHANGE = 0x40; static const uint8_t PN532_COMMAND_INLISTPASSIVETARGET = 0x4A; static const uint8_t PN532_COMMAND_POWERDOWN = 0x16; -enum PN532ReadReady { +enum PN532ReadReady : uint8_t { WOULDBLOCK = 0, TIMEOUT, READY, }; +// SEL_RES (SAK) bits, as reported by InListPassiveTarget for ISO/IEC 14443 type A targets (NXP AN10833) +static constexpr uint8_t SEL_RES_MIFARE_CLASSIC = 0x08; +static constexpr uint8_t SEL_RES_ISO_DEP = 0x20; +static constexpr uint8_t SEL_RES_TNP3XXX = 0x01; // MIFARE Classic 1K compatible + +/// Tag type (nfc::TAG_TYPE_*) from a type A target's SEL_RES byte +inline uint8_t tag_type_from_sel_res(uint8_t sel_res) { + if ((sel_res & SEL_RES_MIFARE_CLASSIC) || sel_res == SEL_RES_TNP3XXX) + return nfc::TAG_TYPE_MIFARE_CLASSIC; + if (sel_res & SEL_RES_ISO_DEP) + return nfc::TAG_TYPE_4; + if (sel_res == 0x00) + return nfc::TAG_TYPE_2; + return nfc::TAG_TYPE_UNKNOWN; +} + class PN532BinarySensor; class PN532 : public PollingComponent { @@ -67,11 +83,14 @@ class PN532 : public PollingComponent { virtual bool read_data(std::vector &data, uint8_t len) = 0; virtual bool read_response(uint8_t command, std::vector &data) = 0; - std::unique_ptr read_tag_(nfc::NfcTagUid &uid); + std::unique_ptr read_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); - bool format_tag_(nfc::NfcTagUid &uid); - bool clean_tag_(nfc::NfcTagUid &uid); - bool write_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); + bool format_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); + bool clean_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); + bool write_tag_(nfc::NfcTagUid &uid, uint8_t tag_type, nfc::NdefMessage *message); + /// Sends an InDataExchange command and reads the response; returns false unless the status byte reports success. + /// On success, `response` holds the data returned by the target, without the status byte. + bool in_data_exchange_(const std::vector &command, std::vector &response); std::unique_ptr read_mifare_classic_tag_(nfc::NfcTagUid &uid); bool read_mifare_classic_block_(uint8_t block_num, std::vector &data); @@ -91,27 +110,32 @@ class PN532 : public PollingComponent { bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); bool clean_mifare_ultralight_(); - bool updates_enabled_{true}; - bool requested_read_{false}; - std::vector binary_sensors_; - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; - nfc::NfcTagUid current_uid_; - nfc::NdefMessage *next_task_message_to_write_; - optional rd_start_time_{}; - enum PN532ReadReady rd_ready_ { WOULDBLOCK }; - enum NfcTask { + enum NfcTask : uint8_t { READ = 0, CLEAN, FORMAT, WRITE, - } next_task_{READ}; - enum PN532Error { + }; + enum PN532Error : uint8_t { NONE = 0, WAKEUP_FAILED, SAM_COMMAND_FAILED, - } error_code_{NONE}; + }; + + // members are ordered by alignment, widest first, to minimize padding CallbackManager on_finished_write_callback_; + std::vector binary_sensors_; + std::vector triggers_ontag_; + std::vector triggers_ontagremoved_; + std::unique_ptr next_task_message_to_write_; + nfc::NfcTagUid current_uid_; + uint32_t rd_start_time_{0}; // valid only while rd_started_ is set + PN532ReadReady rd_ready_{WOULDBLOCK}; + NfcTask next_task_{READ}; + PN532Error error_code_{NONE}; + bool rd_started_{false}; + bool updates_enabled_{true}; + bool requested_read_{false}; }; class PN532BinarySensor final : public binary_sensor::BinarySensor { diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index 37674080d83..166f4ab7761 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -36,14 +36,15 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid if (nfc::mifare_classic_is_first_block(current_block)) { if (!this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { ESP_LOGE(TAG, "Error, Block authentication failed for %d", current_block); + return make_unique(uid, nfc::MIFARE_CLASSIC); } } std::vector block_data; - if (this->read_mifare_classic_block_(current_block, block_data)) { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); - } else { + if (!this->read_mifare_classic_block_(current_block, block_data)) { ESP_LOGE(TAG, "Error reading block %d", current_block); + return make_unique(uid, nfc::MIFARE_CLASSIC); } + buffer.insert(buffer.end(), block_data.begin(), block_data.end()); index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; @@ -63,20 +64,18 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid } bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - if (!this->write_command_({ - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - block_num, - })) { + if (!this->in_data_exchange_( + { + PN532_COMMAND_INDATAEXCHANGE, + 0x01, // One card + nfc::MIFARE_CMD_READ, + block_num, + }, + data) || + data.size() != nfc::MIFARE_CLASSIC_BLOCK_SIZE) { return false; } - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, data) || data[0] != 0x00) { - return false; - } - data.erase(data.begin()); - char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGVV(TAG, " Block %d: %s", block_num, nfc::format_bytes_to(data_buf, data)); return true; @@ -90,14 +89,14 @@ bool PN532::auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, u block_num, // Block number }); data.insert(data.end(), key, key + 6); - data.insert(data.end(), uid.begin(), uid.end()); - if (!this->write_command_(data)) { - ESP_LOGE(TAG, "Authentication failed - Block %d", block_num); + // the command takes exactly 4 UID bytes (UM0701-02, 7.3.8); for 7-byte UIDs these are the last 4, as in libnfc + if (uid.size() < 4) { return false; } + data.insert(data.end(), uid.end() - 4, uid.end()); std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response[0] != 0x00) { + if (!this->in_data_exchange_(data, response)) { ESP_LOGE(TAG, "Authentication failed - Block 0x%02x", block_num); return false; } @@ -167,6 +166,8 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { ESP_LOGD(TAG, "Sector 0 formatted to NDEF"); + bool error = false; + for (int block = 4; block < 64; block += 4) { if (!this->auth_mifare_classic_block_(uid, block + 3, nfc::MIFARE_CMD_AUTH_B, nfc::DEFAULT_KEY)) { return false; @@ -174,23 +175,28 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { if (block == 4) { if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size())) { ESP_LOGE(TAG, "Unable to write block %d", block); + error = true; } } else { if (!this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { ESP_LOGE(TAG, "Unable to write block %d", block); + error = true; } } if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); + error = true; } if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); + error = true; } if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size())) { ESP_LOGE(TAG, "Unable to write trailer block %d", block + 3); + error = true; } } - return true; + return !error; } bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { @@ -201,13 +207,9 @@ bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, block_num, }); cmd.insert(cmd.end(), data, data + len); - if (!this->write_command_(cmd)) { - ESP_LOGE(TAG, "Error writing block %d", block_num); - return false; - } std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response)) { + if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing block %d", block_num); return false; } diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index eb3d13a7e06..d918c9d52d2 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -51,24 +52,20 @@ bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes std::vector response; for (uint8_t i = 0; i * read_increment < num_bytes; i++) { - if (!this->write_command_({ - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), - })) { + // a READ returns 4 pages (16 bytes) + if (!this->in_data_exchange_( + { + PN532_COMMAND_INDATAEXCHANGE, + 0x01, // One card + nfc::MIFARE_CMD_READ, + uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), + }, + response) || + response.size() != read_increment) { return false; } - - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response) || response[0] != 0x00) { - return false; - } - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? response.end() : response.end() - (bytes_offset - num_bytes); - - if ((pages_in_end_itr > response.begin()) && (pages_in_end_itr <= response.end())) { - data.insert(data.end(), response.begin() + 1, pages_in_end_itr); - } + const uint16_t remaining = num_bytes - i * read_increment; + data.insert(data.end(), response.begin(), response.begin() + std::min(read_increment, remaining)); } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -87,7 +84,7 @@ bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_t uint16_t PN532::read_mifare_ultralight_capacity_() { std::vector data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data)) { + if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; } @@ -174,13 +171,9 @@ bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write page_num, }); cmd.insert(cmd.end(), write_data, write_data + len); - if (!this->write_command_(cmd)) { - ESP_LOGE(TAG, "Error writing page %u", page_num); - return false; - } std::vector response; - if (!this->read_response(PN532_COMMAND_INDATAEXCHANGE, response)) { + if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing page %u", page_num); return false; } diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 7f4d78461be..4160076a7de 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -12,11 +12,12 @@ namespace esphome::pn532_i2c { static const char *const TAG = "pn532_i2c"; bool PN532I2C::is_read_ready() { - uint8_t ready; - if (!this->read_bytes_raw(&ready, 1)) { + uint8_t status; + if (!this->read_bytes_raw(&status, 1)) { return false; } - return ready == 0x01; + // only bit 0 (RDY) of the status byte is defined (UM0701-02, 6.2.4) + return status & 0x01; } bool PN532I2C::write_data(const std::vector &data) { @@ -30,9 +31,9 @@ bool PN532I2C::read_data(std::vector &data, uint8_t len) { return false; } + // the PN532 prefixes every frame with a status byte data.resize(len + 1); - this->read_bytes_raw(data.data(), len + 1); - return true; + return this->read_bytes_raw(data.data(), len + 1); } bool PN532I2C::read_response(uint8_t command, std::vector &data) { @@ -73,7 +74,7 @@ bool PN532I2C::read_response(uint8_t command, std::vector &data) { checksum = ~checksum + 1; if (data[len + 1] != checksum) { - ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len], checksum); + ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len + 1], checksum); return false; } diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 13d9aebc20c..112adc4ecb8 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -25,7 +25,8 @@ void PN532Spi::setup() { bool PN532Spi::is_read_ready() { this->enable(); this->write_byte(0x02); - bool ready = this->read_byte() == 0x01; + // only bit 0 (RDY) of the status byte is defined (UM0701-02, 6.2.5) + const bool ready = this->read_byte() & 0x01; this->disable(); return ready; } diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp new file mode 100644 index 00000000000..38c369acbc0 --- /dev/null +++ b/tests/components/pn532/pn532_test.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include "esphome/components/pn532/pn532.h" + +namespace esphome::pn532 { + +namespace { + +// Stands in for the bus: acknowledges every command and answers with queued response payloads. +class FakePN532 : public PN532 { + public: + using PN532::auth_mifare_classic_block_; + using PN532::read_mifare_ultralight_bytes_; + using PN532::read_mifare_classic_block_; + using PN532::write_mifare_classic_block_; + using PN532::write_mifare_ultralight_page_; + + std::deque> responses; + std::vector> written; + + protected: + bool is_read_ready() override { return true; } + bool write_data(const std::vector &data) override { + this->written.push_back(data); + return true; + } + // only used for ACK frames; index 0 is the I2C status byte + bool read_data(std::vector &data, uint8_t len) override { + data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; + return true; + } + bool read_response(uint8_t command, std::vector &data) override { + if (this->responses.empty()) + return false; + data = this->responses.front(); + this->responses.pop_front(); + return true; + } +}; + +// Extracts the command bytes (after TFI) from a normal information frame +std::vector frame_data(const std::vector &frame) { + // preamble, start code (2), LEN, LCS, TFI, data..., DCS, postamble + return std::vector(frame.begin() + 6, frame.end() - 2); +} + +} // namespace + +TEST(PN532TagType, FromSelRes) { + EXPECT_EQ(tag_type_from_sel_res(0x08), nfc::TAG_TYPE_MIFARE_CLASSIC); // Classic 1K + EXPECT_EQ(tag_type_from_sel_res(0x18), nfc::TAG_TYPE_MIFARE_CLASSIC); // Classic 4K + EXPECT_EQ(tag_type_from_sel_res(0x09), nfc::TAG_TYPE_MIFARE_CLASSIC); // Mini + EXPECT_EQ(tag_type_from_sel_res(0x01), nfc::TAG_TYPE_MIFARE_CLASSIC); // TNP3xxx + EXPECT_EQ(tag_type_from_sel_res(0x00), nfc::TAG_TYPE_2); // Ultralight / NTAG + EXPECT_EQ(tag_type_from_sel_res(0x20), nfc::TAG_TYPE_4); // ISO-DEP (phones, DESFire) + EXPECT_EQ(tag_type_from_sel_res(0x40), nfc::TAG_TYPE_UNKNOWN); +} + +// A failed write (status byte other than 0x00) must be reported as a failure. +TEST(PN532Mifare, ClassicWriteChecksStatus) { + FakePN532 pn532; + const uint8_t block[16] = {}; + pn532.responses.push_back({0x14}); // authentication error + EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); +} + +TEST(PN532Mifare, UltralightWriteChecksStatus) { + FakePN532 pn532; + const uint8_t page[4] = {}; + pn532.responses.push_back({0x01}); // timeout + EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); +} + +TEST(PN532Mifare, ClassicReadRejectsBadResponses) { + FakePN532 pn532; + std::vector data; + pn532.responses.emplace_back(); // empty response + EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); + data.clear(); + pn532.responses.push_back({0x00, 0x01, 0x02}); // short block + EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); + + std::vector good(17, 0xAB); + good[0] = 0x00; + pn532.responses.push_back(good); + data.clear(); + EXPECT_TRUE(pn532.read_mifare_classic_block_(4, data)); + EXPECT_EQ(data, std::vector(16, 0xAB)); +} + +// Authentication carries exactly 4 UID bytes: the last 4 of a 7-byte UID. +TEST(PN532Mifare, AuthSendsFourUidBytes) { + FakePN532 pn532; + nfc::NfcTagUid uid = {0x04, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + pn532.responses.push_back({0x00}); + EXPECT_TRUE(pn532.auth_mifare_classic_block_(uid, 4, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)); + ASSERT_EQ(pn532.written.size(), 1u); + const auto cmd = frame_data(pn532.written[0]); + // InDataExchange, Tg, Cmd, Addr, key (6), UID (4) + ASSERT_EQ(cmd.size(), 14u); + EXPECT_EQ(std::vector(cmd.end() - 4, cmd.end()), (std::vector{0x33, 0x44, 0x55, 0x66})); +} + +// Reads in 16-byte chunks, keeps only the bytes asked for, and advances 4 pages per READ. +TEST(PN532Mifare, UltralightReadTrimsLastChunk) { + FakePN532 pn532; + std::vector first(17), second(17); + first[0] = second[0] = 0x00; // status + for (uint8_t i = 0; i < 16; i++) { + first[i + 1] = i; + second[i + 1] = 0x10 + i; + } + pn532.responses.push_back(first); + pn532.responses.push_back(second); + + std::vector data; + ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); + ASSERT_EQ(data.size(), 20u); + EXPECT_EQ(data[15], 15); + EXPECT_EQ(data[16], 0x10); + EXPECT_EQ(data[19], 0x13); + + ASSERT_EQ(pn532.written.size(), 2u); + EXPECT_EQ(frame_data(pn532.written[0]).back(), 4); // READ page 4 + EXPECT_EQ(frame_data(pn532.written[1]).back(), 8); // then page 8 +} + +TEST(PN532Mifare, UltralightReadRejectsBadResponses) { + FakePN532 pn532; + std::vector data; + pn532.responses.push_back({0x00, 0x01, 0x02}); // short response + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + + std::vector failed(17, 0x00); + failed[0] = 0x01; // timeout status + pn532.responses.push_back(failed); + data.clear(); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); +} + +} // namespace esphome::pn532 From b5b6d0bacdb5fd5430a7577884676810092d9b3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 21:00:48 +0100 Subject: [PATCH 581/759] [ci] Add the native ESP8266 compile smoke test (#19714) --- .github/actions/cache-arduino8266/action.yml | 39 +++++ .github/workflows/ci.yml | 68 +++++++- script/determine-jobs.py | 159 ++++++++++++++++--- script/test_build_components.py | 53 ++++++- tests/script/test_determine_jobs.py | 98 ++++++++++++ tests/script/test_test_build_components.py | 95 +++++++++++ 6 files changed, 478 insertions(+), 34 deletions(-) create mode 100644 .github/actions/cache-arduino8266/action.yml diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml new file mode 100644 index 00000000000..affe62b5aa2 --- /dev/null +++ b/.github/actions/cache-arduino8266/action.yml @@ -0,0 +1,39 @@ +name: Cache Arduino ESP8266 +description: > + Resolve the pinned Arduino core and xtensa toolchain versions and cache the + native ESP8266 install (~110 MB framework + toolchain; no ccache store, the + seed job saves before any compile runs). Exports + ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into + the cached path; the Python venv must already be restored. Mirrors + cache-esp-idf: only dev-branch pushes write the shared cache, everything + else restores. +runs: + using: composite + steps: + - name: Resolve the native toolchain cache key + # Versions are pinned in code, not a hashable file; resolve them so a + # bump changes the cache key. Assignment form so errexit catches a + # resolver failure. + id: version + shell: bash + run: | + # One owner for the install prefix: exported here and referenced by + # the cache steps below via env, so the caller's install and the + # cached path cannot diverge. + echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV" + . venv/bin/activate + key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")') + [ -n "$key" ] || exit 1 + echo "key=$key" >> "$GITHUB_OUTPUT" + - name: Cache the native toolchain (write on dev) + if: github.ref == 'refs/heads/dev' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} + key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} + - name: Restore the native toolchain (off dev) + if: github.ref != 'refs/heads/dev' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} + key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2343f8c5baf..11d073e7e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: device-builder: ${{ steps.determine.outputs.device-builder }} esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} + esp8266-native: ${{ steps.determine.outputs.esp8266-native }} + esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -165,6 +167,8 @@ jobs: echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT + echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT + echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -183,6 +187,32 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + seed-esp8266-native-cache: + name: Seed the esp8266 native toolchain cache + runs-on: ubuntu-24.04 + needs: + - common + # PR-branch cache saves are invisible to other PRs, so dev pushes seed + # the shared entry test-esp8266-native restores. Only dev: the composite + # action saves nowhere else, so a beta/release push would download the + # toolchain and discard it. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + timeout-minutes: 15 + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + - name: Install the native toolchain + run: | + . venv/bin/activate + python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)" + ci-custom: name: Run script/ci-custom runs-on: ubuntu-24.04 @@ -1237,7 +1267,7 @@ jobs: # compile validates config first, so a separate config pass is # redundant for this smoke test. ESP-IDF framework via PlatformIO: - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests echo "" echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." @@ -1246,6 +1276,40 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio + test-esp8266-native: + name: Test esp8266 components with the native toolchain + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + env: + # Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS) + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + + - name: Run native toolchain compile test + run: | + . venv/bin/activate + + echo "Testing components: $TEST_COMPONENTS" + echo "" + + # ESP8266 Arduino built directly (no PlatformIO); compile validates + # config first, so a separate config pass is redundant. + python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1608,6 +1672,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1623,6 +1688,7 @@ jobs: - clang-tidy-esp32-variants - test-build-components-split - test-esp32-platformio + - test-esp8266-native - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/determine-jobs.py b/script/determine-jobs.py index f5412af21d6..aa930745814 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -50,6 +50,7 @@ from __future__ import annotations import argparse from collections import Counter +from collections.abc import Callable from enum import StrEnum from functools import cache import json @@ -520,48 +521,69 @@ ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( } ) +# Shared by every toolchain smoke-test job: the base config and the bus +# packages each generated build includes +_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES = ("tests/test_build_components/",) + # Path prefixes whose changes always trigger the PlatformIO compile test: # anything under esphome/platformio/ (the PlatformIO runner / toolchain that # drives every PlatformIO build). The esp32 platform component is already in # ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal # component-narrowing path. -ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ( + "esphome/platformio/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) # Standalone files that, when changed, trigger the PlatformIO compile test: # - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( +# Shared by every toolchain smoke-test job: the harness it invokes and the +# workflow that defines it +_SMOKE_HARNESS_TRIGGER_FILES = frozenset( { - "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) +ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | { + "esphome/build_gen/platformio.py", +} + + +def _path_or_file_trigger( + files: list[str], + trigger_files: frozenset[str], + trigger_prefixes: tuple[str, ...], +) -> bool: + """Whether any changed file matches the given infrastructure triggers.""" + return any( + file in trigger_files or file.startswith(trigger_prefixes) for file in files + ) + + +@cache +def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]: + """Dependency closure of the changed components; cached because the + walk is expensive and every smoke-test job asks for the same list.""" + component_files = [f for f in files if filter_component_and_test_files(f)] + return frozenset(get_components_with_dependencies(component_files, True)) + def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" - for file in files: - if file in ESP32_PLATFORMIO_TRIGGER_FILES: - return True - if any( - file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES - ): - return True - return False + return _path_or_file_trigger( + files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ) def _esp_idf_infra_changed(files: list[str]) -> bool: """Whether any changed file is ESP-IDF build/runner infrastructure.""" - for file in files: - if file in ESP_IDF_INFRA_TRIGGER_FILES: - return True - if any( - file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES - ): - return True - return False + return _path_or_file_trigger( + files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ) def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: @@ -599,15 +621,23 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: Returns: Sorted list of component names to compile. """ + return _toolchain_components_to_test( + branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger + ) + + +def _toolchain_components_to_test( + branch: str | None, + test_set: frozenset[str], + infra_trigger: Callable[[list[str]], bool], +) -> list[str]: + """The shared narrowing rule for the per-toolchain smoke-test jobs.""" files = changed_files(branch) - if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): - return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + if core_changed(files) or infra_trigger(files): + return sorted(test_set) - component_files = [f for f in files if filter_component_and_test_files(f)] - changed = get_components_with_dependencies(component_files, True) - - return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) + return sorted(test_set & _cached_components_closure(tuple(files))) def should_run_esp32_platformio(branch: str | None = None) -> bool: @@ -628,6 +658,79 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool: return bool(esp32_platformio_components_to_test(branch)) +# The `--toolchain arduino` smoke-test set: covers the core, the bundled and +# converted registry libraries, and the waveform path. +ESP8266_NATIVE_TEST_COMPONENTS = frozenset( + { + "esp8266", + "api", + "web_server", + "captive_portal", + "mqtt", + "esp8266_pwm", + "neopixelbus", + "bme280_i2c", + "uart", + } +) + +# Infrastructure whose changes always trigger the native ESP8266 +# compile test +ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = ( + "esphome/arduino8266/", + "esphome/arduino/", + "esphome/build_helpers/", + *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, +) +# Shared library-conversion modules every native build imports; espidf-only +# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set. +_NATIVE_SHARED_TRIGGER_FILES = frozenset( + { + "esphome/framework_helpers.py", + "esphome/platformio/library.py", + "esphome/platformio/extra_script.py", + } +) +# Tripwire: the shared modules must stay in the ESP-IDF trigger set too +# (now defined in clang_tidy_hash), or its smoke test silently skips them +assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES +ESP8266_NATIVE_TRIGGER_FILES = ( + _NATIVE_SHARED_TRIGGER_FILES + | _SMOKE_HARNESS_TRIGGER_FILES + | { + "esphome/build_gen/arduino8266.py", + "esphome/build_gen/build_tool.py", + "esphome/components/esp8266/build_surgery.py", + "esphome/components/esp8266/boards.py", + "esphome/platformio/registry.py", + # esp8266/__init__.py imports copy_ccache_script from it + "esphome/platformio/toolchain.py", + ".github/actions/cache-arduino8266/action.yml", + } +) + + +def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is native-ESP8266 infrastructure / harness.""" + # base_python_changed covers the top-level esphome/*.py modules the + # native backend imports directly (framework_helpers, helpers, writer, + # __main__); without it a change there would silently skip this job. + # base_python_changed is deliberately broad (any top-level esphome/*.py) + # as belt-and-braces while the backend is new; narrow it to the modules + # the backend imports once the toolchain has soaked a few releases + return base_python_changed(files) or _path_or_file_trigger( + files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES + ) + + +def esp8266_native_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to + compile (same narrowing as ``esp32_platformio_components_to_test``).""" + return _toolchain_components_to_test( + branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger + ) + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -1226,6 +1329,8 @@ def main() -> None: run_device_builder = True esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) run_esp32_platformio = True + esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS) + run_esp8266_native = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1237,6 +1342,8 @@ def main() -> None: run_device_builder = should_run_device_builder(args.branch) esp32_platformio_components = esp32_platformio_components_to_test(args.branch) run_esp32_platformio = bool(esp32_platformio_components) + esp8266_native_components = esp8266_native_components_to_test(args.branch) + run_esp8266_native = bool(esp8266_native_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1432,6 +1539,8 @@ def main() -> None: "device_builder": run_device_builder, "esp32_platformio": run_esp32_platformio, "esp32_platformio_components": ",".join(esp32_platformio_components), + "esp8266_native": run_esp8266_native, + "esp8266_native_components": ",".join(esp8266_native_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/test_build_components.py b/script/test_build_components.py index ddd8a6a67d7..d3dfd360763 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -1027,6 +1027,7 @@ def test_components( isolated_components: set[str] | None = None, base_only: bool = False, toolchain: str | None = None, + fail_on_no_tests: bool = False, ) -> int: """Test components with optional intelligent grouping. @@ -1061,20 +1062,32 @@ def test_components( # toolchain build. include_validate = esphome_command != "compile" - # Find all component tests + # A blank pattern list would slide into the reference-baseline + # fallback and exit green while building nothing + if fail_on_no_tests and not any(component_patterns): + print("No components requested (blank component list)") + return 1 + + # Find all component tests; remember which components each pattern + # (wildcards included) matched, for the deferred no-tests accounting all_tests = {} + pattern_components: dict[str, set[str]] = {} for pattern in component_patterns: # Skip empty patterns (happens when components list is empty string) if not pattern: continue - all_tests.update( - find_component_tests( - tests_dir, pattern, base_only, include_validate=include_validate - ) + found = find_component_tests( + tests_dir, pattern, base_only, include_validate=include_validate ) + pattern_components[pattern] = set(found) + all_tests.update(found) + + if fail_on_no_tests and not all_tests: + # Nothing matched: fail before the synthetic baseline spends a + # compile reporting success on nothing + print(f"No components found matching: {component_patterns}") + return 1 - # If no components found, build a reference configuration for baseline comparison - # Create a synthetic "empty" component test that will build just the base config if not all_tests: print(f"No components found matching: {component_patterns}") print( @@ -1178,6 +1191,23 @@ def test_components( toolchain=toolchain, ) + silent: list[str] = [] + if fail_on_no_tests: + # A green run that built nothing for a requested pattern must not + # pass CI. Per pattern so one silent pattern cannot hide behind + # the others; opt-in because some legs legitimately match nothing; + # deferred past the summary so reproduce commands still print. + built = {c for r in test_results for c in r.components} + # A pattern is silent when it matched no fixture, or when none of + # its matched components produced a build (wildcards included) + silent = [ + p + for p in component_patterns + if p and not (pattern_components.get(p, set()) & built) + ] + if silent: + print(f"No tests ran for requested pattern(s): {', '.join(silent)}") + # Separate results into passed and failed passed_results = [r for r in test_results if r.success] failed_results = [r for r in test_results if not r.success] @@ -1209,7 +1239,7 @@ def test_components( if os.environ.get("GITHUB_STEP_SUMMARY"): write_github_summary(test_results, toolchain=toolchain) - if failed_results: + if failed_results or silent: return 1 return 0 @@ -1264,6 +1294,12 @@ def main() -> int: "--toolchain", help="Select toolchain for compiling.", ) + parser.add_argument( + "--fail-on-no-tests", + action="store_true", + help="Exit non-zero when no test matched (for CI legs whose " + "components must all have fixtures)", + ) args = parser.parse_args() @@ -1282,6 +1318,7 @@ def main() -> int: continue_on_fail=args.continue_on_fail, enable_grouping=not args.no_grouping, isolated_components=isolated_components, + fail_on_no_tests=args.fail_on_no_tests, base_only=args.base_only, toolchain=args.toolchain, ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 49718219698..4903f1a88ad 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -78,6 +78,17 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]: + """Mock esp8266_native_components_to_test from determine_jobs. + + main() drives both the ``esp8266_native`` boolean output and the + ``esp8266_native_components`` CSV from this one function. + """ + with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -106,6 +117,7 @@ def clear_determine_jobs_caches() -> None: """Clear all cached functions before each test.""" determine_jobs._is_clang_tidy_full_scan.cache_clear() determine_jobs._component_has_tests.cache_clear() + determine_jobs._cached_components_closure.cache_clear() def test_main_all_tests_should_run( @@ -116,6 +128,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -132,6 +145,7 @@ def test_main_all_tests_should_run( mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] + mock_esp8266_native_components_to_test.return_value = ["api", "logger"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -208,6 +222,8 @@ def test_main_all_tests_should_run( assert output["device_builder"] is True assert output["esp32_platformio"] is True assert output["esp32_platformio_components"] == "api,esp32" + assert output["esp8266_native"] is True + assert output["esp8266_native_components"] == "api,logger" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -244,6 +260,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -260,6 +277,7 @@ def test_main_no_tests_should_run( mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -302,6 +320,8 @@ def test_main_no_tests_should_run( assert output["device_builder"] is False assert output["esp32_platformio"] is False assert output["esp32_platformio_components"] == "" + assert output["esp8266_native"] is False + assert output["esp8266_native_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -997,6 +1017,9 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], + # The base config and bus packages every generated build includes + ["tests/test_build_components/build_components_base.esp32-idf.yaml"], + ["tests/test_build_components/common/uart/esp32-idf.yaml"], ] @@ -3151,6 +3174,81 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None: assert find_elf_path(build_path) == elf, f"{platform} ELF not found" +@pytest.mark.parametrize( + "changed", + [ + "esphome/arduino8266/framework.py", + "esphome/build_gen/arduino8266.py", + "esphome/components/esp8266/build_surgery.py", + # Shared modules the native build depends on + "esphome/build_helpers/idedata.py", + "esphome/platformio/library.py", + # Top-level esphome/*.py modules the backend imports directly + "esphome/framework_helpers.py", + "esphome/writer.py", + # esp8266/__init__.py imports copy_ccache_script from it + "esphome/platformio/toolchain.py", + # The composite cache action must not ship unexercised + ".github/actions/cache-arduino8266/action.yml", + # The base config and bus packages every generated build includes + "tests/test_build_components/build_components_base.esp8266-ard.yaml", + "tests/test_build_components/common/uart/esp8266-ard.yaml", + ], +) +def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None: + """Native-ESP8266 infrastructure changes run the full test list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=["wifi"], + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS) + + +@pytest.mark.parametrize( + ("changed_files", "dependency_closure", "expected"), + [ + # Tested component changed -- narrow to the intersection. + ( + ["esphome/components/mqtt/mqtt_client.cpp"], + ["mqtt", "json"], + ["mqtt"], + ), + # Components outside the test set return an empty list (job skipped). + ( + ["esphome/components/wifi/wifi_component.cpp"], + ["wifi", "network"], + [], + ), + # espidf infrastructure is not an esp8266-native trigger; the + # native backend depends on esphome/build_helpers/ instead. + (["esphome/build_gen/espidf.py"], [], []), + (["esphome/espidf/toolchain.py"], [], []), + (["README.md"], [], []), + ], +) +def test_esp8266_native_components_to_test_narrowing( + changed_files: list[str], + dependency_closure: list[str], + expected: list[str], +) -> None: + """Component changes narrow the native-ESP8266 test list.""" + with ( + patch.object(determine_jobs, "changed_files", return_value=changed_files), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=dependency_closure, + ), + ): + result = determine_jobs.esp8266_native_components_to_test() + assert result == expected + + def test_compute_integration_test_buckets_no_durations_full_fanout() -> None: """Without recorded durations the fan-out stays at the maximum.""" files = [f"tests/integration/test_{i:03d}.py" for i in range(15)] diff --git a/tests/script/test_test_build_components.py b/tests/script/test_test_build_components.py index 74e150380c5..1d21e5d943e 100644 --- a/tests/script/test_test_build_components.py +++ b/tests/script/test_test_build_components.py @@ -236,3 +236,98 @@ def test_run_grouped_test_closes_group_when_subprocess_raises( ) assert "::endgroup::" in capsys.readouterr().out + + +def test_components_empty_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """Under --fail-on-no-tests, a real component filtered to a platform + with no matching test file must not pass CI as a green zero-component + compile.""" + rc = tbc.test_components( + ["logger"], + "zz-none", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_component_with_no_base_file_fails_with_flag( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A component whose fixture matches the platform but whose platform has + no base file builds nothing; under the flag that silent zero fails by + component name instead of hiding behind other components.""" + monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {}) + rc = tbc.test_components( + ["logger"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out) + + +def test_components_blank_list_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A fully blank component list must not slide into the baseline + fallback and exit green under the flag.""" + rc = tbc.test_components( + [""], "esp8266-ard", "compile", False, fail_on_no_tests=True + ) + assert rc == 1 + assert "blank component list" in capsys.readouterr().out + + +def test_components_wildcard_no_match_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A wildcard matching nothing must not degrade to the synthetic + baseline build and exit green under the flag.""" + rc = tbc.test_components( + ["zz_no_such*"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out + + +def test_components_empty_match_tolerated_without_flag() -> None: + """The esp32-ard smoke leg deliberately builds only the subset with a + matching fixture; without the flag an empty match stays green.""" + assert ( + tbc.test_components( + ["logger"], "zz-none", "compile", False, enable_grouping=False + ) + == 0 + ) + + +def test_components_unknown_component_fails_with_flag( + capsys: pytest.CaptureFixture[str], +) -> None: + """A renamed smoke-test component must shrink coverage loudly, not fall + into the reference-baseline build.""" + rc = tbc.test_components( + ["no_such_component_xyz"], + "esp8266-ard", + "compile", + False, + enable_grouping=False, + fail_on_no_tests=True, + ) + assert rc == 1 + assert "No components found matching" in capsys.readouterr().out From e7574c1d9c4293c2ca3fe90110cd73027f3bc94f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 22:17:14 +0100 Subject: [PATCH 582/759] [media_player] Name the player in the test automations so grouping works (#19741) --- tests/components/media_player/common.yaml | 89 +++++++++++++---------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index dd6773864d6..dcd04e017ad 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -13,60 +13,75 @@ media_player: speaker: test_speaker format: NONE on_state: - - media_player.play: - - media_player.play_media: http://localhost/media.mp3 - - media_player.play_media: !lambda |- - return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + - media_player.play: test_media_player + - media_player.play_media: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.play_media: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; on_idle: - - media_player.pause: + - media_player.pause: test_media_player on_play: + - media_player.stop: test_media_player - media_player.stop: - - media_player.stop: + id: test_media_player announcement: true - media_player.stop: + id: test_media_player announcement: !lambda 'return id(test_media_player).is_muted();' on_announcement: - - media_player.play: + - media_player.play: test_media_player on_turn_on: - - media_player.play: + - media_player.play: test_media_player on_turn_off: - - media_player.stop: + - media_player.stop: test_media_player on_pause: - - media_player.toggle: - - media_player.turn_on: - - media_player.turn_off: - - media_player.next: - - media_player.previous: - - media_player.mute: - - media_player.unmute: - - media_player.repeat_off: - - media_player.repeat_one: - - media_player.repeat_all: - - media_player.shuffle: - - media_player.unshuffle: - - media_player.group_join: - - media_player.clear_playlist: + - media_player.toggle: test_media_player + - media_player.turn_on: test_media_player + - media_player.turn_off: test_media_player + - media_player.next: test_media_player + - media_player.previous: test_media_player + - media_player.mute: test_media_player + - media_player.unmute: test_media_player + - media_player.repeat_off: test_media_player + - media_player.repeat_one: test_media_player + - media_player.repeat_all: test_media_player + - media_player.shuffle: test_media_player + - media_player.unshuffle: test_media_player + - media_player.group_join: test_media_player + - media_player.clear_playlist: test_media_player - wait_until: - media_player.is_idle: + media_player.is_idle: test_media_player - wait_until: - media_player.is_playing: + media_player.is_playing: test_media_player - wait_until: - media_player.is_announcing: + media_player.is_announcing: test_media_player - wait_until: - media_player.is_paused: + media_player.is_paused: test_media_player - wait_until: - media_player.is_on: + media_player.is_on: test_media_player - wait_until: - media_player.is_off: + media_player.is_off: test_media_player - wait_until: - media_player.is_muted: - - media_player.volume_up: - - media_player.volume_down: - - media_player.volume_set: 50% - - media_player.volume_set: !lambda 'return id(test_media_player).volume / 2;' - - media_player.enqueue: http://localhost/media.mp3 - - media_player.enqueue: !lambda |- - return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + media_player.is_muted: test_media_player + - media_player.volume_up: test_media_player + - media_player.volume_down: test_media_player + - media_player.volume_set: + id: test_media_player + volume: 50% + - media_player.volume_set: + id: test_media_player + volume: !lambda 'return id(test_media_player).volume / 2;' - media_player.enqueue: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.enqueue: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + - media_player.enqueue: + id: test_media_player media_url: http://localhost/media.mp3 announcement: true From b1594348f176ff0fcec7a934468101430121fb26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 22:34:47 +0100 Subject: [PATCH 583/759] [tfluna] Name the shared bus in the test fixture so grouping works (#19742) --- tests/components/tfluna/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/tfluna/common.yaml b/tests/components/tfluna/common.yaml index 5804ae6981b..57ecf892861 100644 --- a/tests/components/tfluna/common.yaml +++ b/tests/components/tfluna/common.yaml @@ -1,4 +1,5 @@ tfluna: + - i2c_id: i2c_bus sensor: - platform: tfluna From 7dd26ba9cc19cc6e0852885f982b50e58bddec39 Mon Sep 17 00:00:00 2001 From: CAT the Tech <88114675+catthetech@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:41:34 -0400 Subject: [PATCH 584/759] [lvgl] Ensure final animation frame completes before restart (#19642) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/animation.h | 47 +++--- tests/components/lvgl/__init__.py | 15 ++ tests/components/lvgl/animation_test.cpp | 184 +++++++++++++++++++++++ 3 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 tests/components/lvgl/__init__.py create mode 100644 tests/components/lvgl/animation_test.cpp diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 26bb433f874..05b84926eb6 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -4,6 +4,7 @@ #ifdef USE_LVGL_ANIMATION #include "lvgl_esphome.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::lvgl { @@ -110,17 +111,8 @@ template class LvAnimation : public C } void start() { - if (this->state_ > AnimationState::STOPPED) - this->stop(); - if (this->duration_ == 0) + if (!this->prepare_()) return; - // evaluate any lambdas - for (size_t i = 0; i != DATA_SIZE; i++) { - this->data_from_[i] = this->from_[i].value(); - this->data_to_[i] = this->to_[i].value(); - } - this->start_time_ = millis(); - this->state_ = AnimationState::STARTED; this->loop(); this->start_callback_.call(); } @@ -143,29 +135,26 @@ template class LvAnimation : public C if (this->state_ == AnimationState::STOPPED) return; uint32_t elapsed = millis() - this->start_time_; - float progress = static_cast(elapsed) / static_cast(this->duration_); + float progress = clamp_at_most(elapsed / static_cast(this->duration_), 1.0f); switch (this->state_) { case AnimationState::STARTED: if (elapsed < this->start_delay_) return; this->state_ = AnimationState::RUNNING; this->start_time_ = millis(); + elapsed = 0; progress = 0.0f; break; case AnimationState::RUNNING: - if (progress >= 1.0f) { - progress = 1.0f; - this->stop(); - if (this->loop_) - this->start(); - } break; default: return; } + // state here is RUNNING. for (auto *timing : this->timings_) { - progress = timing->map_progress(progress); + // avoid overshooting + progress = clamp_at_most(timing->map_progress(progress), 1.0f); } lv_coord_t data[DATA_SIZE]; for (size_t i = 0; i != DATA_SIZE; i++) { @@ -173,6 +162,12 @@ template class LvAnimation : public C roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); } this->update_callback_(data); + if (elapsed >= this->duration_) { + this->stop(); + // Restart without drawing the first frame now, so the final frame is not overwritten before it is shown + if (this->loop_ && this->prepare_()) + this->start_callback_.call(); + } } float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } @@ -187,6 +182,22 @@ template class LvAnimation : public C template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } protected: + // Arms a new run. Returns false if there is nothing to run. + bool prepare_() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return false; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + return true; + } + void (*const update_callback_)(const lv_coord_t *data); LazyCallbackManager start_callback_{}; LazyCallbackManager stop_callback_{}; diff --git a/tests/components/lvgl/__init__.py b/tests/components/lvgl/__init__.py new file mode 100644 index 00000000000..4c8e202cf91 --- /dev/null +++ b/tests/components/lvgl/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components.lvgl import LVGL_VERSION +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs a display and widgets. The unit tests only need the LVGL + # headers and the animation code. + async def to_code_testing(config: ConfigType) -> None: + cg.add_library("lvgl/lvgl", LVGL_VERSION) + cg.add_build_flag("-DLV_CONF_SKIP=1") + cg.add_define("USE_LVGL_ANIMATION") + + manifest.to_code = to_code_testing diff --git a/tests/components/lvgl/animation_test.cpp b/tests/components/lvgl/animation_test.cpp new file mode 100644 index 00000000000..0ccb412f043 --- /dev/null +++ b/tests/components/lvgl/animation_test.cpp @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include +#include "esphome/components/lvgl/animation.h" + +namespace esphome::lvgl::testing { + +namespace { + +// The update callback is a plain function pointer, so it cannot capture; results go to a global. +std::vector updates; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void record_update(const lv_coord_t *data) { updates.push_back(data[0]); } + +// Exposes the running state so tests can wait for the animation to finish. +class TestAnimation : public LvAnimation<1> { + public: + TestAnimation(lv_coord_t from, lv_coord_t to, uint32_t duration_ms, uint32_t start_delay_ms = 0) + : LvAnimation<1>(record_update, {TemplatableValue(from)}, {TemplatableValue(to)}) { + this->set_duration(duration_ms); + this->set_start_delay(start_delay_ms); + this->add_on_start_callback([this]() { this->start_count++; }); + this->add_on_stop_callback([this]() { this->stop_count++; }); + } + + bool is_running() const { return this->state_ != AnimationState::STOPPED; } + + // Returns true if the animation stopped before the timeout. + bool run_until_stopped(uint32_t timeout_ms = 1000) { + const uint32_t begin = millis(); + while (this->is_running() && millis() - begin < timeout_ms) { + this->loop(); + delay(1); + } + return !this->is_running(); + } + + void run_for(uint32_t duration_ms) { + const uint32_t begin = millis(); + while (millis() - begin < duration_ms) { + this->loop(); + delay(1); + } + } + + int start_count{0}; + int stop_count{0}; +}; + +lv_coord_t max_update() { + lv_coord_t result = updates.front(); + for (auto value : updates) + result = std::max(result, value); + return result; +} + +} // namespace + +class LvAnimationTest : public ::testing::Test { + protected: + void SetUp() override { updates.clear(); } +}; + +TEST_F(LvAnimationTest, LinearRunsToEndAndStopsOnce) { + TestAnimation anim(0, 100, 20); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.front(), 0); + EXPECT_EQ(updates.back(), 100); + EXPECT_EQ(anim.start_count, 1); + EXPECT_EQ(anim.stop_count, 1); + + // Nothing more happens once stopped. + const size_t update_count = updates.size(); + anim.run_for(10); + EXPECT_EQ(updates.size(), update_count); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, ZeroDurationDoesNotStart) { + TestAnimation anim(0, 100, 0); + anim.start(); + EXPECT_FALSE(anim.is_running()); + EXPECT_EQ(anim.start_count, 0); + EXPECT_TRUE(updates.empty()); +} + +TEST_F(LvAnimationTest, StartDelayHoldsBackUpdates) { + TestAnimation anim(0, 100, 20, 30); + anim.start(); + EXPECT_TRUE(anim.is_running()); + EXPECT_TRUE(updates.empty()); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +// Round trip maps the end of the duration back to the start value, so completion must not depend on the +// mapped value reaching 1.0. +TEST_F(LvAnimationTest, RoundTripStopsAtStartValue) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +// The pause maps to 1.0 in the middle of the duration; the animation must still play the return leg. +TEST_F(LvAnimationTest, RoundTripWithPausePlaysReturnLeg) { + LvAnimationTimingRoundTrip timing(0.5f); + TestAnimation anim(0, 100, 40); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(max_update(), 100); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, GravityStops) { + LvAnimationTimingGravity timing(0.5f, 0.5f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, EaseInOutEndsAtTarget) { + LvAnimationTimingEaseInOut timing(1.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +TEST_F(LvAnimationTest, LoopRestartsOnlyAfterReachingEnd) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + anim.stop(); + + EXPECT_GE(anim.start_count, 2); + // Each restart is a drop in value, and must follow a completed cycle that reached the end value. + for (size_t i = 1; i < updates.size(); i++) { + if (updates[i] < updates[i - 1]) + EXPECT_EQ(updates[i - 1], 100) << "update " << i << " restarted before the end was reached"; + } +} + +// The first frame of the next cycle must not be drawn in the same pass as the final frame of the last one. +TEST_F(LvAnimationTest, LoopRestartKeepsFinalFrame) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + const uint32_t begin = millis(); + while (millis() - begin < 100) { + const size_t before = updates.size(); + anim.loop(); + ASSERT_LE(updates.size() - before, 1u); + delay(1); + } + anim.stop(); + EXPECT_GE(anim.start_count, 2); +} + +TEST_F(LvAnimationTest, LoopWithRoundTripKeepsCycling) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + EXPECT_GE(anim.start_count, 2); + EXPECT_GE(anim.stop_count, 1); + anim.stop(); +} + +} // namespace esphome::lvgl::testing From 2d17ad2c9172ef1ff18fa06f2c032b340b618ac9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Sep 2026 22:45:04 +0100 Subject: [PATCH 585/759] [pn532] Use register_apply_condition for pn532.is_writing (#19737) --- esphome/components/pn532/__init__.py | 8 ++------ esphome/components/pn532/pn532.h | 6 ------ tests/components/pn532_i2c/common-actions.yaml | 10 ++++++++++ .../components/pn532_i2c/test-actions.esp8266-ard.yaml | 4 ++++ 4 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 tests/components/pn532_i2c/common-actions.yaml create mode 100644 tests/components/pn532_i2c/test-actions.esp8266-ard.yaml diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index d396050511f..b23c4e845ab 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -20,10 +20,6 @@ CONF_PN532_ID = "pn532_id" pn532_ns = cg.esphome_ns.namespace("pn532") PN532 = pn532_ns.class_("PN532", cg.PollingComponent) -PN532IsWritingCondition = pn532_ns.class_( - "PN532IsWritingCondition", automation.Condition -) - PN532_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(PN532), @@ -77,12 +73,12 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -automation.register_parented_condition( +automation.register_apply_condition( "pn532.is_writing", - PN532IsWritingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(PN532), } ), + "is_writing()", ) diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index c518afc4914..96013f7d796 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/nfc/nfc_tag.h" #include "esphome/components/nfc/nfc.h" @@ -156,9 +155,4 @@ class PN532BinarySensor final : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - } // namespace esphome::pn532 diff --git a/tests/components/pn532_i2c/common-actions.yaml b/tests/components/pn532_i2c/common-actions.yaml new file mode 100644 index 00000000000..2f5bd4aec42 --- /dev/null +++ b/tests/components/pn532_i2c/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: PN532 write state + on_press: + - if: + condition: + pn532.is_writing: + id: pn532_nfcc_i2c + then: + - logger.log: Writing diff --git a/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9a413421266 --- /dev/null +++ b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pn532_i2c: !include common.yaml + actions: !include common-actions.yaml From 5d415ad50547bacd1beed2853869b376021734f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 05:04:41 +0100 Subject: [PATCH 586/759] [nrf52] Stream the SDK clone and resume it after an interruption (#19731) --- esphome/components/nrf52/framework.py | 92 ++++++++++++++++------- tests/unit_tests/test_nrf52_framework.py | 94 +++++++++++++++++++++++- 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 5e2cf197fbb..ebfbbe41b2f 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -247,6 +247,70 @@ def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: tmp.replace(uf2conv) +def _west_update(env_python_path: Path, framework_path: Path) -> bool: + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + # Streamed so the per-project progress of the long clone reaches the log + return run_command_ok(cmd, cwd=framework_path, stream_output=True) + + +def _install_framework( + env_python_path: Path, framework_path: Path, version: str +) -> None: + """Clone the nRF Connect SDK into ``framework_path`` with west. + + A download cut short after ``west init`` leaves the workspace behind; + rerunning ``west update`` there only fetches what is missing, so it resumes + instead of cloning about 2 GB again. A resume that fails keeps what was + fetched (a flaky network is the likely cause) and is retried on the next + build; only a second failure in a row starts over clean. + """ + resume_failed = framework_path / ".resume_failed" + # Resume only a workspace whose ``west init`` finished (it writes the + # config last). ``.ready`` with missing requirements is a damaged install, + # not an interrupted one, so it goes the clean way. + initialized = (framework_path / ".west" / "config").is_file() + if initialized and not (framework_path / ".ready").exists(): + _LOGGER.info("Resuming the nRF Connect SDK %s download ...", version) + if _west_update(env_python_path, framework_path): + resume_failed.unlink(missing_ok=True) + return + if not resume_failed.exists(): + resume_failed.touch() + raise EsphomeError( + f"Can't resume the nRF Connect SDK {version} download; " + "the next build retries it" + ) + _LOGGER.warning( + "Resuming failed again; downloading nRF Connect SDK %s anew", version + ) + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "-o=--depth=1", + "--mr", + version, + str(framework_path), + ] + if not run_command_ok(cmd, stream_output=True): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + if not _west_update(env_python_path, framework_path): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + + def check_and_install() -> None: version = _get_version_str() python_env_path = _get_python_env_path(version) @@ -280,33 +344,7 @@ def check_and_install() -> None: sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" if not sentinel.exists() or not zephyr_reqs.exists(): - rmdir(framework_path, msg=f"Clean up {version} framework environment") - _LOGGER.info("Initializing nRF Connect SDK %s ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "init", - "-m", - "https://github.com/nrfconnect/sdk-nrf", - "-o=--depth=1", - "--mr", - version, - str(framework_path), - ] - if not run_command_ok(cmd): - raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") - _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "update", - "--narrow", - "--fetch-opt=--depth=1", - ] - if not run_command_ok(cmd, cwd=framework_path): - raise EsphomeError(f"Can't update nRF Connect SDK {version}") + _install_framework(env_python_path, framework_path, version) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): _patch_uf2conv_escape_sequences(framework_path) diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index b78a94a2e7c..05805a1d57f 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -5,7 +5,7 @@ import os from pathlib import Path import sys from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import ANY, call, patch import platformdirs import pytest @@ -129,6 +129,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_west_initialized(framework: Path) -> None: + """What a finished ``west init`` leaves behind.""" + (framework / ".west").mkdir() + (framework / ".west" / "config").touch() + + 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") @@ -250,6 +256,92 @@ class TestCheckAndInstall: assert "-o=--depth=1" in init_cmd assert "update" in update_cmd assert "--fetch-opt=--depth=1" in update_cmd + # Streamed, so the long clone's progress reaches the log + for west_call in mock_nrf52_ops.run_command_ok.call_args_list[:2]: + assert west_call.kwargs["stream_output"] is True + + def test_interrupted_download_resumes( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace left by a cut-short download is updated in place, not + wiped and cloned again.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + + # A marker left by an earlier failed resume + (nrf52_dirs.framework / ".resume_failed").touch() + + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list + ) + assert not (nrf52_dirs.framework / ".resume_failed").exists() + # west update (no init), then pip install zephyr reqs + first = mock_nrf52_ops.run_command_ok.call_args_list[0] + assert "update" in first.args[0] + assert "init" not in first.args[0] + assert first.kwargs["cwd"] == nrf52_dirs.framework + assert mock_nrf52_ops.run_command_ok.call_count == 2 + assert (nrf52_dirs.framework / ".ready").exists() + + def test_failed_resume_keeps_the_download_once( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A first failed resume keeps what was fetched (the network likely + dropped again) and is retried on the next build.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + mock_nrf52_ops.run_command_ok.return_value = False + + with pytest.raises(EsphomeError, match="Can't resume"): + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list + ) + assert (nrf52_dirs.framework / ".resume_failed").exists() + + def test_cut_short_init_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A ``.west`` without its config (init cut short) clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".west").mkdir() + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + first = mock_nrf52_ops.run_command_ok.call_args_list[0] + assert "init" in first.args[0] + + def test_second_failed_resume_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A resume failing twice in a row wipes the workspace and clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".resume_failed").touch() + # resumed update fails; clean init, update and zephyr reqs succeed + mock_nrf52_ops.run_command_ok.side_effect = [False, True, True, True] + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + commands = [c.args[0] for c in mock_nrf52_ops.run_command_ok.call_args_list] + assert "update" in commands[0] + assert "init" in commands[1] + assert "update" in commands[2] def test_requirements_install_failure_raises( self, From 89ce3ab98c72a5aac199040562420a8770f94236 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 05:05:26 +0100 Subject: [PATCH 587/759] [nrf52] Run analyze-memory on the sdk-nrf toolchain (#19730) --- esphome/__main__.py | 14 +++--- esphome/build_helpers/native.py | 18 ++++++++ esphome/components/nrf52/framework.py | 11 +++++ esphome/components/nrf52/toolchain.py | 28 ++++++++++++ tests/unit_tests/test_main.py | 9 +++- tests/unit_tests/test_nrf52_toolchain.py | 54 ++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 esphome/components/nrf52/toolchain.py create mode 100644 tests/unit_tests/test_nrf52_toolchain.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 707ad036096..c3502db4bae 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks -from esphome.build_helpers.native import native_backend +from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -2015,8 +2015,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Refuse an unsupported toolchain before paying for a full compile - native_toolchain = native_backend() - if native_toolchain is None and not CORE.using_toolchain_platformio: + analysis_toolchain = analysis_backend() + if analysis_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( "analyze-memory is not supported with the '%s' toolchain on %s; " "re-run with --toolchain platformio", @@ -2036,9 +2036,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if native_toolchain is not None: - objdump = native_toolchain.get_objdump_path() - readelf = native_toolchain.get_readelf_path() + if analysis_toolchain is not None: + objdump = analysis_toolchain.get_objdump_path() + readelf = analysis_toolchain.get_readelf_path() for tool in (objdump, readelf): if not tool.is_file(): # The analyzer would silently fall back to host @@ -2052,7 +2052,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: objdump_path = str(objdump) readelf_path = str(readelf) - firmware_elf = native_toolchain.get_elf_path() + firmware_elf = analysis_toolchain.get_elf_path() if not firmware_elf.is_file(): # The analyzer swallows tool failures, so a missing ELF would # produce an exit-0 zeroed report diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py index cde526b8033..24499c3ccde 100644 --- a/esphome/build_helpers/native.py +++ b/esphome/build_helpers/native.py @@ -32,3 +32,21 @@ def native_backend() -> ModuleType | None: f"module for platform {CORE.target_platform}" ) return importlib.import_module(module_path) + + +# Binutils and the linked image for memory analysis, for toolchains that build +# without PlatformIO but have no native build backend (which supplies them) +ANALYSIS_TOOLCHAIN_MODULES = { + ("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain", +} + + +def analysis_backend() -> ModuleType | None: + """The module giving objdump, readelf and the ELF of a non-PlatformIO build. + + None means PlatformIO's idedata supplies them (or nothing can). + """ + if (native := native_backend()) is not None: + return native + module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) + return importlib.import_module(module_path) if module_path else None diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index ebfbbe41b2f..6e57c46c7e3 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -80,6 +80,17 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def toolchain_tool(name: str) -> Path: + """Path to one of the pinned Zephyr SDK's tools (objdump, readelf, ...). + + The single owner of the ``arm-zephyr-eabi/bin/arm-zephyr-eabi-`` + layout and the Windows suffix. + """ + suffix = ".exe" if os.name == "nt" else "" + bin_path = _get_toolchain_path(TOOLCHAIN_VERSION) / "arm-zephyr-eabi" / "bin" + return bin_path / f"arm-zephyr-eabi-{name}{suffix}" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree diff --git a/esphome/components/nrf52/toolchain.py b/esphome/components/nrf52/toolchain.py new file mode 100644 index 00000000000..aaa317aa5f5 --- /dev/null +++ b/esphome/components/nrf52/toolchain.py @@ -0,0 +1,28 @@ +"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis.""" + +from pathlib import Path + +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + +from .framework import toolchain_tool + + +def get_objdump_path() -> Path: + return toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return toolchain_tool("readelf") + + +def get_elf_path() -> Path: + """The linked Zephyr image, at the layout the configured SDK version writes. + + Chosen by version rather than by probing so a leftover image from another + SDK layout is never analyzed in place of the current build. + """ + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2): + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b5e7181269b..4f11be0cb8d 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7489,6 +7489,8 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: [ (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + # No native build backend, but its binutils and ELF are known + (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), ], ) def test_command_analyze_memory_native_toolchains( @@ -7708,7 +7710,12 @@ def test_command_analyze_memory_unsupported_toolchain( mock_write_cpp.return_value = 0 mock_compile_program.return_value = 0 - result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for + # one that does not + with patch.dict( + "esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True + ): + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) assert result == 1 assert "analyze-memory is not supported" in caplog.text diff --git a/tests/unit_tests/test_nrf52_toolchain.py b/tests/unit_tests/test_nrf52_toolchain.py new file mode 100644 index 00000000000..ee8f6eaf499 --- /dev/null +++ b/tests/unit_tests/test_nrf52_toolchain.py @@ -0,0 +1,54 @@ +"""Tests for the nRF52 sdk-nrf memory analysis hooks.""" + +import os +from pathlib import Path + +import pytest + +from esphome.components.nrf52 import toolchain +from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +@pytest.fixture +def nrf52_build(tmp_path: Path) -> Path: + CORE.name = "test_device" + CORE.build_path = tmp_path / "build" / "test_device" + return CORE.build_path / ".pioenvs" / "test_device" / "zephyr" + + +def _set_sdk_version(version: cv.Version) -> None: + CORE.data.setdefault(KEY_CORE, {})[KEY_FRAMEWORK_VERSION] = version + + +def test_binutils_come_from_the_pinned_zephyr_sdk() -> None: + bin_path = ( + get_sdk_nrf_tools_path() + / "toolchains" + / TOOLCHAIN_VERSION + / "arm-zephyr-eabi" + / "bin" + ) + # Windows hosts get the .exe binaries (CI covers both) + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}" + assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}" + + +def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 9, 2)) + # A flat image left by an older SDK must not be picked over the current + # layout, so the choice follows the version rather than what exists + (nrf52_build / "zephyr.elf").parent.mkdir(parents=True) + (nrf52_build / "zephyr.elf").write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr" / "zephyr.elf" + + +def test_elf_uses_the_flat_layout_before_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 6, 1)) + nested = nrf52_build / "zephyr" / "zephyr.elf" + nested.parent.mkdir(parents=True) + nested.write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf" From b619df886a4dbc42f38ebab190c145a4a67d712a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:30:30 +0200 Subject: [PATCH 588/759] [hoermann_hcp] Add the motor's serial number and firmware version as text sensors (#19543) Co-authored-by: J. Nick Koston --- .../components/hoermann_hcp/hoermann_hcp.cpp | 230 ++++++++ .../components/hoermann_hcp/hoermann_hcp.h | 63 +++ .../hoermann_hcp/text_sensor/__init__.py | 37 ++ esphome/core/defines.h | 1 + tests/components/hoermann_hcp/common.h | 20 +- tests/components/hoermann_hcp/common.yaml | 7 + .../hoermann_hcp/text_sensor/__init__.py | 11 + .../hoermann_hcp_text_sensor_test.cpp | 510 ++++++++++++++++++ 8 files changed, 876 insertions(+), 3 deletions(-) create mode 100644 esphome/components/hoermann_hcp/text_sensor/__init__.py create mode 100644 tests/components/hoermann_hcp/text_sensor/__init__.py create mode 100644 tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 4aa2c79bb15..261996e5570 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,9 @@ #include "hoermann_hcp.h" +#include + #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hoermann_hcp { @@ -60,6 +63,61 @@ static bool is_moving(DoorState state) { } } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +// The command byte of a status poll. Only its answer can carry a request. +static constexpr uint8_t STATUS_COMMAND = 0x03; +// A status answer with this code in the low byte of its second register asks the bus controller for a value, +// named in the high byte of the third. +static constexpr uint8_t ANSWER_REQUEST = 0x22; +static constexpr uint8_t REQUEST_SERIAL = 0x05; +static constexpr uint8_t REQUEST_FIRMWARE = 0x06; +// Each request goes out in one answer, up to this many times, this far apart. +static constexpr uint8_t IDENTITY_MAX_ATTEMPTS = 3; +static constexpr uint32_t IDENTITY_RETRY_MS = 30000; +// The value comes back as a payload transfer: this command in the low byte of the first command register, a sub +// code in the high byte of the second, the payload from the third on. +static constexpr uint8_t TRANSFER_COMMAND = 0x04; +static constexpr uint8_t TRANSFER_SUB_SERIAL = 0x0C; +static constexpr uint8_t TRANSFER_SUB_FIRMWARE = 0x0D; +static constexpr uint8_t TRANSFER_ACK = 0xFD; +static constexpr size_t TRANSFER_PAYLOAD_REG = 2; +// Marks the first half of the serial number in the counter byte, and is not part of the count. +static constexpr uint8_t COUNTER_FIRST_HALF = 0x80; +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. It is as long +// as a second half, so one copy path serves both. +static constexpr size_t SERIAL_SINGLE_FRAME_REGS = 6; +static_assert(SERIAL_SINGLE_FRAME_REGS == SERIAL_SECOND_HALF_REGS, "one copy path serves both serial frames"); + +// Registers hold two payload bytes each, high byte first. +static void copy_payload(const modbus::RegisterValues ®isters, size_t count, char *out) { + for (size_t i = 0; i < count; i++) { + const uint16_t value = registers[TRANSFER_PAYLOAD_REG + i]; + out[2 * i] = static_cast(value >> 8); + out[2 * i + 1] = static_cast(value); + } +} + +// Length of the printable text at the start, without trailing spaces. The padding after it varies. +static size_t text_length(const char *text, size_t len) { + size_t at = 0; + while (at < len && text[at] >= 0x20 && text[at] <= 0x7E) + at++; + while (at > 0 && text[at - 1] == ' ') + at--; + return at; +} + +static void terminate_text(char *text, size_t len) { text[text_length(text, len)] = '\0'; } + +// dump_config() is replayed to remote log clients, so they see the outcome of the exchange at boot. +static void log_identity_value(text_sensor::TextSensor *sensor) { + if (sensor != nullptr) { + ESP_LOGCONFIG(TAG, " Value: %s", + sensor->has_state() ? sensor->get_state().c_str() : LOG_STR_LITERAL("not received")); + } +} +#endif + void HoermannHcp::update() { const uint32_t now = millis(); // Time out the connection flag if the bus controller stopped polling. @@ -91,6 +149,9 @@ void HoermannHcp::update() { ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); this->forget_light_toggles_(); } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->publish_identity_(); +#endif if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -102,6 +163,12 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); + log_identity_value(this->serial_number_text_sensor_); + LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_); + log_identity_value(this->version_text_sensor_); +#endif } modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, @@ -113,6 +180,14 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add this->record_response_(); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // Acknowledge the transfer taken by the write half of this frame. + if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) { + this->push_transfer_answer_(registers, number_of_registers); + return {}; + } +#endif + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message // counter (high byte) and command (low byte). The read length identifies which internal block is requested. const uint16_t counter = this->command_reg_value_ & 0xFF00; @@ -125,6 +200,9 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add registers.push_back(static_cast(0x0001 | command)); this->push_command_registers_(registers); push_zeros(registers, 4); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->add_identity_request_(registers, command); +#endif break; case 2: // Empty command request. @@ -156,6 +234,9 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, // command byte back from STATE_REG. The hub always runs the write before the read within one request. this->record_response_(); this->command_reg_value_ = registers[0]; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + this->transfer_answer_counter_ = this->take_identity_transfer_(registers); +#endif return {}; } @@ -217,6 +298,155 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { registers.push_back(command->released_value_2); } +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +void HoermannHcp::add_identity_request_(modbus::RegisterValues ®isters, uint16_t command) { + if (static_cast(this->command_reg_value_) != STATUS_COMMAND) + return; + // Like Hoermann's own bus accessory, only after one ordinary answer. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE) { + if (this->serial_number_text_sensor_ != nullptr || this->version_text_sensor_ != nullptr) + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL); + return; + } + // Uses the registers of a key press, so it waits while one is pending. + if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_()) + return; + registers[1] = static_cast(ANSWER_REQUEST | command); + registers[2] = encode_uint16(this->identity_request_(), 0); +} + +void HoermannHcp::arm_identity_request_(IdentityPhase phase) { + this->identity_phase_ = phase; + this->identity_attempts_ = 0; +} + +bool HoermannHcp::take_identity_request_() { + const uint8_t request = this->identity_request_(); + if (request == 0) + return false; + const uint32_t now = millis(); + if (this->identity_attempts_ != 0 && now - this->identity_asked_at_ <= IDENTITY_RETRY_MS) + return false; + if (this->identity_attempts_ >= IDENTITY_MAX_ATTEMPTS) { + this->identity_unanswered_ = request; + if (request == REQUEST_SERIAL) { + // Still ask for the firmware version, and drop a leftover first half. + this->serial_number_[0] = '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + } else { + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + } + return false; + } + this->identity_attempts_++; + this->identity_asked_at_ = now; + return true; +} + +uint8_t HoermannHcp::take_identity_transfer_(const modbus::RegisterValues ®isters) { + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG || + static_cast(registers[0]) != TRANSFER_COMMAND) + return NO_TRANSFER_ANSWER; + const uint8_t counter = static_cast(registers[0] >> 8); + const uint8_t sub_code = static_cast(registers[1] >> 8); + if (sub_code != TRANSFER_SUB_SERIAL && sub_code != TRANSFER_SUB_FIRMWARE) + return NO_TRANSFER_ANSWER; + // Acknowledged whether kept or not, as Hoermann's own bus accessory does. What was not kept is asked for again. + const uint8_t answer = counter & ~COUNTER_FIRST_HALF; + // Only an answer to the request that went out is kept. + const uint8_t request = sub_code == TRANSFER_SUB_SERIAL ? REQUEST_SERIAL : REQUEST_FIRMWARE; + if (this->identity_attempts_ == 0 || this->identity_request_() != request) + return answer; + + const size_t payload_regs = registers.size() - TRANSFER_PAYLOAD_REG; + if (request == REQUEST_FIRMWARE) { + const size_t regs = std::min(payload_regs, FIRMWARE_REGS); + copy_payload(registers, regs, this->firmware_version_); + if (regs == FIRMWARE_REGS && text_length(this->firmware_version_, 2 * regs) != 0) { + terminate_text(this->firmware_version_, 2 * regs); + // Clear an earlier unreadable answer, so this one is shown. + this->firmware_unreadable_ = false; + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + // Left as received for update() to log. + this->firmware_unreadable_ = true; + this->firmware_unreadable_len_ = 2 * regs; + // A short answer is asked for again; unreadable text would come back the same. + if (regs == FIRMWARE_REGS) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + if ((counter & COUNTER_FIRST_HALF) != 0) { + // Even a half too short to keep says the number comes in two halves; a first half already kept stays kept. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT; + if (payload_regs >= SERIAL_FIRST_HALF_REGS) { + copy_payload(registers, SERIAL_FIRST_HALF_REGS, this->serial_number_); + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF; + } + return answer; + } + // Without a half marker seen, the frame is the whole number; after one, it can only be the second half. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT || payload_regs < SERIAL_SINGLE_FRAME_REGS) + return answer; + const size_t at = + this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF ? 2 * SERIAL_FIRST_HALF_REGS : 0; + copy_payload(registers, SERIAL_SINGLE_FRAME_REGS, this->serial_number_ + at); + terminate_text(this->serial_number_, at + 2 * SERIAL_SINGLE_FRAME_REGS); + this->serial_unreadable_ = this->serial_number_[0] == '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + return answer; +} + +void HoermannHcp::push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers) { + const uint16_t answer[] = {encode_uint16(this->transfer_answer_counter_, 0), + encode_uint16(TRANSFER_COMMAND, TRANSFER_ACK)}; + this->transfer_answer_counter_ = NO_TRANSFER_ANSWER; + for (uint16_t i = 0; i < number_of_registers; i++) + registers.push_back(i < 2 ? answer[i] : 0x0000); +} + +void HoermannHcp::publish_identity_() { + // Buffers are cleared once published, so each value goes out once. The serial number is whole once the firmware + // version is requested. + if (this->serial_number_text_sensor_ != nullptr && this->identity_request_() != REQUEST_SERIAL && + this->serial_number_[0] != '\0') { + this->serial_number_text_sensor_->publish_state(this->serial_number_); + this->serial_number_[0] = '\0'; + } + if (this->serial_unreadable_) { + this->serial_unreadable_ = false; + ESP_LOGW(TAG, "Unreadable serial number"); + } + // Checked first: the buffer then holds raw bytes, not text. + if (this->firmware_unreadable_) { + this->firmware_unreadable_ = false; + const uint8_t len = this->firmware_unreadable_len_; + // All zeros: the motor does not report a version (index B1 seen). + if (len == 2 * FIRMWARE_REGS && + std::all_of(this->firmware_version_, this->firmware_version_ + len, [](char c) { return c == '\0'; })) { + ESP_LOGD(TAG, "Motor does not report its firmware version"); + } else { + char hex[format_hex_size(2 * FIRMWARE_REGS)]; + ESP_LOGW(TAG, "Unreadable firmware version (%u bytes): %s", len, + format_hex_to(hex, reinterpret_cast(this->firmware_version_), len)); + } + this->firmware_version_[0] = '\0'; + } + if (this->version_text_sensor_ != nullptr && this->firmware_version_[0] != '\0') { + this->version_text_sensor_->publish_state(this->firmware_version_); + this->firmware_version_[0] = '\0'; + } + if (this->identity_unanswered_ != 0) { + ESP_LOGW(TAG, "No usable %s received", + this->identity_unanswered_ == REQUEST_SERIAL ? LOG_STR_LITERAL("serial number") + : LOG_STR_LITERAL("firmware version")); + this->identity_unanswered_ = 0; + } +} +#endif + void HoermannHcp::on_position_reg_(uint16_t value) { // Low byte: current position. const uint8_t position = static_cast(value); diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 83be385c7b1..fd52a689d0a 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -4,7 +4,11 @@ #include "esphome/components/modbus/modbus.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::hoermann_hcp { @@ -21,6 +25,28 @@ enum class DoorState : uint8_t { STOPPED, }; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +// Payload registers of each value, two bytes each. +static constexpr size_t SERIAL_FIRST_HALF_REGS = 7; +static constexpr size_t SERIAL_SECOND_HALF_REGS = 6; +static constexpr size_t FIRMWARE_REGS = 6; +// Counter of the transfer the read half of the frame acknowledges. Real counters have the 0x80 half marker +// stripped, so this value never occurs. +static constexpr uint8_t NO_TRANSFER_ANSWER = 0xFF; + +// Where the identity exchange stands. The low nibble is the code the motor is asked with, 0 while nothing is +// outstanding. Like Hoermann's own bus accessory, the first status answer stays ordinary. Older motors (index B1 +// seen) send the whole serial number in one frame, without the half marker. +enum class IdentityPhase : uint8_t { + IDENTITY_PHASE_IDLE = 0x00, + IDENTITY_PHASE_DONE = 0x10, + IDENTITY_PHASE_SERIAL = 0x05, // A plain frame is the whole serial number. + IDENTITY_PHASE_SERIAL_SPLIT = 0x15, // A half marker was seen, so a plain frame can only be the second half. + IDENTITY_PHASE_SERIAL_SECOND_HALF = 0x25, // The first half is in. + IDENTITY_PHASE_FIRMWARE = 0x06, +}; +#endif + // 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. Each half also carries a second register, which names the buttons that do // not fit into the first. @@ -35,6 +61,12 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // The motor is asked for these only when one of them is configured. + SUB_TEXT_SENSOR(serial_number) + SUB_TEXT_SENSOR(version) +#endif + public: void update() override; void dump_config() override; @@ -95,6 +127,21 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); void on_light_reg_(uint16_t value); +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + // Puts a due request into a status answer. + void add_identity_request_(modbus::RegisterValues ®isters, uint16_t command); + void arm_identity_request_(IdentityPhase phase); + uint8_t identity_request_() const { return static_cast(this->identity_phase_) & 0x0F; } + // True when the request is due, counting the attempt. Gives up after the last one. + bool take_identity_request_(); + // Takes a value the motor hands over as a payload transfer. Returns the counter to acknowledge it with, kept or + // not, or NO_TRANSFER_ANSWER when the frame was something else. + uint8_t take_identity_transfer_(const modbus::RegisterValues ®isters); + // Acknowledges the transfer taken by the write half of the same frame. + void push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers); + // Runs from update(), outside the bus callbacks. + void publish_identity_(); +#endif void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -146,6 +193,22 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_on_{false}; bool light_seen_{false}; bool short_broadcast_logged_{false}; + +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + uint32_t identity_asked_at_{0}; + IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE}; + uint8_t identity_attempts_{0}; + // The request given up on, for update() to report. + uint8_t identity_unanswered_{0}; + uint8_t transfer_answer_counter_{NO_TRANSFER_ANSWER}; + // Length of an unreadable firmware version left in firmware_version_. + uint8_t firmware_unreadable_len_{0}; + // A serial number arrived without text, for update() to log. + bool serial_unreadable_{false}; + bool firmware_unreadable_{false}; + char serial_number_[2 * (SERIAL_FIRST_HALF_REGS + SERIAL_SECOND_HALF_REGS) + 1]{}; + char firmware_version_[2 * FIRMWARE_REGS + 1]{}; +#endif }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..0fcbe600403 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,37 @@ +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_HOERMANN_HCP_ID, HoermannHcp + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_SERIAL_NUMBER = "serial_number" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( + icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_CHIP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_SERIAL_NUMBER, CONF_VERSION), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + if (conf := config.get(CONF_SERIAL_NUMBER)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_serial_number_text_sensor(sens)) + if (conf := config.get(CONF_VERSION)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_version_text_sensor(sens)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75c7a76a408..19b3f475be1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -77,6 +77,7 @@ #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_TEXT_SENSOR #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index a6151697f0f..fb940d790fa 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -35,11 +35,18 @@ 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})); +// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter +// with command 0x03 here; most tests do not care and pass zero. +inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) { + door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000})); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); + return response; +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + const RegisterValues response = status_answer(door); EXPECT_EQ(response.size(), 8u); if (response.size() != 8u) return {0xFFFF, 0xFFFF}; @@ -59,7 +66,14 @@ class TestableHoermannHcp : public HoermannHcp { TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } using HoermannHcp::connection_timeout_ms_; +#ifdef USE_HOERMANN_HCP_TEXT_SENSOR + using HoermannHcp::identity_asked_at_; + using HoermannHcp::identity_request_; + using HoermannHcp::firmware_unreadable_; + using HoermannHcp::serial_unreadable_; +#endif using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::key_press_delay_ms_; using HoermannHcp::light_toggle_released_at_; using HoermannHcp::light_toggles_in_flight_; using HoermannHcp::set_valid_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 618a8181bfb..394afed3603 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -22,3 +22,10 @@ button: light: - platform: hoermann_hcp name: Garage Light + +text_sensor: + - platform: hoermann_hcp + serial_number: + name: Garage Motor Serial Number + version: + name: Garage Motor Firmware Version diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..693c48b736f --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + + manifest.to_code = to_code_testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp new file mode 100644 index 00000000000..8dff56e2a36 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp @@ -0,0 +1,510 @@ +#include +#include + +#include +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Made up. 26 bytes on the wire: the first 14 arrive in one transfer, the other 12 in the next. +constexpr const char *SERIAL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +// Made up as well, padded with a space to the 12 bytes the motor sends. +constexpr const char *FIRMWARE = "FW-TEST 1.0 "; + +constexpr uint8_t SUB_SERIAL = 0x0C; +constexpr uint8_t SUB_FIRMWARE = 0x0D; +constexpr uint8_t FIRST_HALF = 0x80; + +// A status poll as the bus controller writes it: its counter in the high byte, command 0x03 in the low. +RegisterValues status_poll(HoermannHcp &door, uint8_t counter = 0x05) { + return status_answer(door, static_cast((counter << 8) | 0x03)); +} + +// The write half of a payload transfer: the motor writes the bytes into the command block. +void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len) { + RegisterValues written; + written.push_back(static_cast((counter << 8) | 0x04)); + written.push_back(static_cast(sub_code << 8)); + for (size_t i = 0; i < len; i += 2) { + written.push_back( + static_cast((static_cast(bytes[i]) << 8) | static_cast(bytes[i + 1]))); + } + door.on_write_registers(COMMAND_REG, written); +} + +// A whole payload transfer, returning the answer the motor reads back. +RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len, + uint16_t read_registers = 8) { + write_transfer(door, counter, sub_code, bytes, len); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, read_registers, response); + return response; +} + +// The first status poll gets an ordinary answer, the next one carries the serial number request. +void request_serial(HoermannHcp &door) { + status_poll(door, 0x03); + status_poll(door, 0x04); +} + +void send_serial(HoermannHcp &door) { + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); +} + +// A hub with both sensors configured, which is what makes it ask. +struct IdentityFixture { + IdentityFixture() { + this->door.set_serial_number_text_sensor(&this->serial); + this->door.set_version_text_sensor(&this->version); + } + // What the sensors show once the loop has turned; empty while they have no state. + std::string serial_shown() { + this->door.update(); + return this->serial.has_state() ? this->serial.get_state() : ""; + } + std::string version_shown() { + this->door.update(); + return this->version.has_state() ? this->version.get_state() : ""; + } + + TestableHoermannHcp door; + text_sensor::TextSensor serial; + text_sensor::TextSensor version; +}; + +// The whole exchange as the motor runs it, with the loop turning in between as it would. +void run_identity_exchange(HoermannHcp &door) { + request_serial(door); + send_serial(door); + door.update(); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + door.update(); +} + +} // namespace + +// With no sensor configured, polls and transfers are answered exactly as before. +TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) { + HoermannHcp door; + for (int poll = 0; poll < 2; poll++) { + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0301); + EXPECT_EQ(response[2], 0x0000); + } + const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + EXPECT_EQ(answer[1] & 0x00FF, 0x0001); +} + +// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the +// request, echoing the status command like any status answer. It is asked again only once 30 s have passed. +TEST(HoermannHcpTextSensorTest, SerialNumberIsAskedForAfterOneOrdinaryAnswer) { + IdentityFixture fixture; + auto &door = fixture.door; + EXPECT_EQ(status_poll(door)[1], 0x0301); + + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[0], 0x0500); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + + door.identity_asked_at_ -= 29000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + door.identity_asked_at_ -= 2000; + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Three attempts at the serial number, then the firmware version is asked for anyway, three times as well. +TEST(HoermannHcpTextSensorTest, GivesUpAfterThreeAttemptsEach) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A first half left behind by a serial number that never completed is not shown. +TEST(HoermannHcpTextSensorTest, HalfASerialNumberIsNeverShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + for (int attempt = 0; attempt < 4; attempt++) { + door.identity_asked_at_ -= 31000; + status_poll(door); + } + EXPECT_EQ(fixture.serial_shown(), ""); +} + +// Each half is acknowledged with the counter it came with, minus the half marker. The serial number is shown as +// soon as it is whole, and the firmware version is asked for right after. +TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + + RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + ASSERT_EQ(answer.size(), 8u); + EXPECT_EQ(answer[0], 0x0500); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), ""); + + answer = transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(answer[0], 0x0600); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + EXPECT_EQ(fixture.version_shown(), ""); + + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + + answer = transfer(door, 0x07, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// Once both values are in, nothing more is asked, however long the device keeps running. +TEST(HoermannHcpTextSensorTest, FinishedExchangeStaysFinished) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + + door.identity_asked_at_ -= 31000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// The text ends at the first byte that is not printable. 0xFF is below the printable range where char is signed, +// as on the host, and above it where char is unsigned, as on most targets. DEL is above it either way. +TEST(HoermannHcpTextSensorTest, PaddingEndsTheText) { + for (const char pad : {'\xFF', '\x7F'}) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char first[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', pad}; + const char second[] = {pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad}; + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, first, sizeof(first)); + transfer(door, 0x06, SUB_SERIAL, second, sizeof(second)); + EXPECT_EQ(fixture.serial_shown(), "1234567890123"); + } +} + +// A half that cannot be used is still acknowledged but not kept, so the request stays open for the retry: a +// first half too short, a second half too short, a second half without a first. +TEST(HoermannHcpTextSensorTest, UnusableSerialHalvesAreNotKept) { + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12)[1], 0x04FD); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 10); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } +} + +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. The bytes are +// the ones a B1 sent, with the serial number made up. +TEST(HoermannHcpTextSensorTest, SerialNumberInOneFrameThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x05, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + auto answer = status_poll(door, 0x06); + EXPECT_EQ(answer[1], 0x0322); + EXPECT_EQ(answer[2], 0x0600); +} + +// The frames as a B1 motor sends them on the bus, which reads a transfer answer back as 2 registers. The serial +// number is made up. +TEST(HoermannHcpTextSensorTest, ExchangeWithTheFrameSizesOfAB1) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_THAT(transfer(door, 0x05, SUB_SERIAL, one_frame, 12, 2), ::testing::ElementsAre(0x0500, 0x04FD)); + + EXPECT_THAT(status_poll(door, 0x06), ::testing::ElementsAre(0x0600, 0x0322, 0x0600, 0, 0, 0, 0, 0)); + const char zeros[12] = {}; + EXPECT_THAT(transfer(door, 0x07, SUB_FIRMWARE, zeros, 12, 2), ::testing::ElementsAre(0x0700, 0x04FD)); + + EXPECT_EQ(door.identity_request_(), 0); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_EQ(status_poll(door, 0x08)[1], 0x0301); +} + +// A transfer of the value not asked for is acknowledged but not kept, and the request stays open. +TEST(HoermannHcpTextSensorTest, TheValueNotAskedForIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, 0x05, SUB_FIRMWARE, FIRMWARE, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.version_shown(), ""); + + send_serial(door); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + status_poll(door, 0x07); + const char other[14] = {'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'}; + EXPECT_EQ(transfer(door, FIRST_HALF | 0x08, SUB_SERIAL, other, 14)[1], 0x04FD); + EXPECT_EQ(transfer(door, 0x09, SUB_SERIAL, other, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), SERIAL); +} + +// A transfer before the request has gone out, as from a motor still finishing an exchange from before a restart, +// is acknowledged but not kept. +TEST(HoermannHcpTextSensorTest, ATransferBeforeTheRequestIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x04, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(status_poll(door, 0x05)[1], 0x0322); +} + +// After a frame with the half marker, one without it can only be the second half, even if the first was unusable. +TEST(HoermannHcpTextSensorTest, ASecondHalfIsNeverTakenForTheWholeNumber) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); +} + +// A serial number without any text at its start is not shown but logged, in one frame or in two halves. The +// firmware version is still asked for. +TEST(HoermannHcpTextSensorTest, SerialNumberThatIsNotTextIsLoggedNotShown) { + const char zeros[14] = {}; + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, 0x05, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); // kept for the log + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); // logged once + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, zeros, 14); + transfer(door, 0x06, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); + } +} + +// A firmware version too short is not kept, and is asked for again. +TEST(HoermannHcpTextSensorTest, ShortFirmwareVersionIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + EXPECT_TRUE(door.firmware_unreadable_); // kept for the log + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); // logged once + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A firmware version without any payload is logged, not shown. +TEST(HoermannHcpTextSensorTest, EmptyFirmwareVersionIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 0); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A readable firmware version right after a short one, before the loop has turned, is still shown. +TEST(HoermannHcpTextSensorTest, ReadableFirmwareVersionAfterAShortOneIsShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + transfer(door, 0x09, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// All zeros is how a motor that does not report its version says so: nothing shown, not asked for again. +TEST(HoermannHcpTextSensorTest, AllZeroFirmwareVersionMeansNoneIsReported) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char zeros[12] = {}; + transfer(door, 0x08, SUB_FIRMWARE, zeros, 12); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A firmware version that is not text is not shown, is logged, and is not asked for again: it would come back +// the same. +TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char binary[12] = {0x01, 0x12, 0x34, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}; + transfer(door, 0x08, SUB_FIRMWARE, binary, 12); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); // the raw bytes are not published + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a +// status poll instead, it would carry the key press waiting in the slot. +TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + connect_controller(door); + door.open_door(); + + const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(answer[2], 0x0000); + EXPECT_EQ(status_poll(door)[2], 0x0210); +} + +// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves +// nothing behind for the next poll. +TEST(HoermannHcpTextSensorTest, AnAnswerBelongsToItsFrame) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + write_transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + RegisterValues ignored; + door.on_read_holding_registers(COMMAND_REG, 8, ignored); + + EXPECT_EQ(status_poll(door)[1] & 0x00FF, 0x0022); +} + +// Only the answer to a status poll carries a request, not the answer to another frame of the same length, and +// other transfers are not this exchange's to answer. +TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2); + EXPECT_EQ(other[1] & 0x00FF, 0x0001); + EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322); +} + +// The request travels in the registers a key press would, so it waits for the press, the hold and the release. +TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + door.key_press_delay_ms_ = 100; + connect_controller(door); + status_poll(door); + door.open_door(); + + EXPECT_EQ(status_poll(door)[2], 0x0210); + const RegisterValues held = status_poll(door); + EXPECT_EQ(held[1], 0x0301); + EXPECT_EQ(held[2], 0x0000); + door.key_press_delay_ms_ = 0; + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + const RegisterValues release = status_poll(door); + EXPECT_EQ(release[1], 0x0301); + EXPECT_EQ(release[2], 0x0110); + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Each value is published once it is in, and only once. +TEST(HoermannHcpTextSensorTest, EachValueIsPublishedOnce) { + IdentityFixture fixture; + int serial_publishes = 0; + int version_publishes = 0; + fixture.serial.add_on_state_callback([&serial_publishes](const std::string & /*state*/) { serial_publishes++; }); + fixture.version.add_on_state_callback([&version_publishes](const std::string & /*state*/) { version_publishes++; }); + + fixture.door.update(); + EXPECT_EQ(serial_publishes, 0); + EXPECT_EQ(version_publishes, 0); + + run_identity_exchange(fixture.door); + EXPECT_EQ(fixture.serial.get_state(), SERIAL); + EXPECT_EQ(fixture.version.get_state(), "FW-TEST 1.0"); + + fixture.door.update(); + fixture.door.update(); + EXPECT_EQ(serial_publishes, 1); + EXPECT_EQ(version_publishes, 1); +} + +// Configuring only one of the two is enough to ask. +TEST(HoermannHcpTextSensorTest, OneSensorIsEnough) { + TestableHoermannHcp version_only; + text_sensor::TextSensor version; + version_only.set_version_text_sensor(&version); + run_identity_exchange(version_only); + EXPECT_EQ(version.get_state(), "FW-TEST 1.0"); + + TestableHoermannHcp serial_only; + text_sensor::TextSensor serial; + serial_only.set_serial_number_text_sensor(&serial); + run_identity_exchange(serial_only); + EXPECT_EQ(serial.get_state(), SERIAL); +} + +} // namespace esphome::hoermann_hcp::testing From 514ee178f9e9670ef329eed94f6e9b36fb66413d Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 26 Sep 2026 23:55:38 -0500 Subject: [PATCH 589/759] [pn532] Remove runtime heap allocation from the frame and tag paths (#19744) Co-authored-by: Claude Fable 5.1 --- esphome/components/nfc/ndef_message.cpp | 2 +- esphome/components/nfc/ndef_message.h | 4 +- esphome/components/nfc/nfc.cpp | 24 +++- esphome/components/nfc/nfc.h | 6 +- esphome/components/pn532/__init__.py | 7 + esphome/components/pn532/binary_sensor.py | 3 + esphome/components/pn532/pn532.cpp | 83 ++++++++---- esphome/components/pn532/pn532.h | 72 +++++++--- .../components/pn532/pn532_mifare_classic.cpp | 124 +++++++++--------- .../pn532/pn532_mifare_ultralight.cpp | 79 +++++------ esphome/components/pn532_i2c/pn532_i2c.cpp | 33 +++-- esphome/components/pn532_i2c/pn532_i2c.h | 8 +- esphome/components/pn532_spi/pn532_spi.cpp | 23 ++-- esphome/components/pn532_spi/pn532_spi.h | 8 +- esphome/core/defines.h | 3 + esphome/core/helpers.h | 3 + tests/components/pn532/pn532_test.cpp | 74 ++++++++--- 17 files changed, 356 insertions(+), 200 deletions(-) diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index d33f3f7b5cb..d9135cbdba6 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { static const char *const TAG = "nfc.ndef_message"; -NdefMessage::NdefMessage(std::vector &data) { +NdefMessage::NdefMessage(const std::span data) { ESP_LOGV(TAG, "Building NdefMessage with %zu bytes", data.size()); size_t index = 0; while (index < data.size()) { diff --git a/esphome/components/nfc/ndef_message.h b/esphome/components/nfc/ndef_message.h index 7d431b22963..0d7699e9d72 100644 --- a/esphome/components/nfc/ndef_message.h +++ b/esphome/components/nfc/ndef_message.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esphome/core/helpers.h" @@ -16,7 +17,8 @@ static constexpr uint8_t MAX_NDEF_RECORDS = 4; class NdefMessage { public: NdefMessage() = default; - NdefMessage(std::vector &data); + NdefMessage(std::span data); + NdefMessage(std::vector &data) : NdefMessage(std::span(data)) {} NdefMessage(const NdefMessage &msg) { records_.reserve(msg.records_.size()); for (const auto &r : msg.records_) { diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 64f3228b3c2..a9fc1c2b8c4 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -23,7 +23,7 @@ uint8_t guess_tag_type(uint8_t uid_length) { } } -int8_t get_mifare_classic_ndef_start_index(std::vector &data) { +int8_t get_mifare_classic_ndef_start_index(const std::span data) { for (uint8_t i = 0; i < MIFARE_CLASSIC_BLOCK_SIZE; i++) { if (data[i] == 0x00) { // Do nothing, skip @@ -36,7 +36,8 @@ int8_t get_mifare_classic_ndef_start_index(std::vector &data) { return -1; } -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index) { +bool decode_mifare_classic_tlv(const std::span data, uint32_t &message_length, + uint8_t &message_start_index) { if (data.size() < MIFARE_CLASSIC_BLOCK_SIZE) { ESP_LOGE(TAG, "Error, data too short for NDEF detection."); return false; @@ -68,6 +69,25 @@ uint32_t get_mifare_ultralight_buffer_size(uint32_t message_length) { return buffer_size; } +void fill_ndef_tlv(const std::span message, const uint32_t buffer_length, FixedVector &buffer) { + buffer.init(buffer_length); + buffer.push_back(0x03); + if (message.size() < 255) { + buffer.push_back(message.size()); + } else { + buffer.push_back(0xFF); + buffer.push_back((message.size() >> 8) & 0xFF); + buffer.push_back(message.size() & 0xFF); + } + for (const uint8_t byte : message) { + buffer.push_back(byte); + } + buffer.push_back(0xFE); + while (buffer.size() < buffer_length) { + buffer.push_back(0x00); + } +} + uint32_t get_mifare_classic_buffer_size(uint32_t message_length) { uint32_t buffer_size = message_length; if (message_length < 255) { diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 36b27ce5f68..603cb8d3148 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -64,9 +64,11 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; char *format_bytes_to(char *buffer, std::span bytes); uint8_t guess_tag_type(uint8_t uid_length); -int8_t get_mifare_classic_ndef_start_index(std::vector &data); -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); +int8_t get_mifare_classic_ndef_start_index(std::span data); +bool decode_mifare_classic_tlv(std::span data, uint32_t &message_length, uint8_t &message_start_index); uint32_t get_mifare_classic_buffer_size(uint32_t message_length); +/// Fills `buffer` with the NDEF TLV (type, length, message, terminator) padded with zeros to `buffer_length` +void fill_ndef_tlv(std::span message, uint32_t buffer_length, FixedVector &buffer); bool mifare_classic_is_first_block(uint8_t block_num); bool mifare_classic_is_trailer_block(uint8_t block_num); diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index b23c4e845ab..f4f16a8b890 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -46,6 +46,11 @@ def CONFIG_SCHEMA(conf: ConfigType) -> None: ) +_request_ontag_trigger_slot = cg.slot_counter("PN532_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN532_ON_TAG_REMOVED_TRIGGER_COUNT" +) + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" @@ -58,6 +63,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf @@ -65,6 +71,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG_REMOVED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index 8f490ba7d0f..b9cf9a1c279 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -34,6 +34,8 @@ def validate_uid(value: Any) -> str: PN532BinarySensor = pn532_ns.class_("PN532BinarySensor", binary_sensor.BinarySensor) +_request_binary_sensor_slot = cg.slot_counter("PN532_BINARY_SENSOR_COUNT") + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( { cv.GenerateID(CONF_PN532_ID): cv.use_id(PN532), @@ -46,6 +48,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) + _request_binary_sensor_slot(str(hub)) cg.add(hub.register_tag(var)) addr = [HexInt(int(x, 16)) for x in config[CONF_UID].split("-")] cg.add(var.set_uid(addr)) diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 17ee11d7d94..91593484c87 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -1,5 +1,7 @@ #include "pn532.h" +#include +#include #include #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -25,7 +27,7 @@ void PN532::setup() { } } - std::vector version_data; + PN532Frame version_data; // GetFirmwareVersion returns IC, Ver, Rev and Support if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { ESP_LOGE(TAG, "Error getting version"); @@ -45,7 +47,7 @@ void PN532::setup() { return; } - std::vector wakeup_result; + PN532Frame wakeup_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, wakeup_result)) { this->error_code_ = WAKEUP_FAILED; this->mark_failed(); @@ -65,7 +67,7 @@ void PN532::setup() { return; } - std::vector sam_result; + PN532Frame sam_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, sam_result)) { ESP_LOGV(TAG, "Invalid SAM result: (%u)", sam_result.size()); // NOLINT for (uint8_t dat : sam_result) { @@ -87,7 +89,7 @@ bool PN532::powerdown() { ESP_LOGE(TAG, "Error writing powerdown command to PN532"); return false; } - std::vector response; + PN532Frame response; if (!this->read_response(PN532_COMMAND_POWERDOWN, response)) { ESP_LOGE(TAG, "Error reading PN532 powerdown response"); return false; @@ -105,8 +107,10 @@ void PN532::update() { if (!updates_enabled_) return; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *obj : this->binary_sensors_) obj->on_scan_end(); +#endif if (!this->write_command_({ PN532_COMMAND_INLISTPASSIVETARGET, @@ -130,7 +134,7 @@ void PN532::loop() { return; bool success = false; - std::vector read; + PN532Frame read; if (ready == READY) { success = this->read_response(PN532_COMMAND_INLISTPASSIVETARGET, read); @@ -142,11 +146,13 @@ void PN532::loop() { if (!success) { // Something failed +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; @@ -155,11 +161,13 @@ void PN532::loop() { uint8_t num_targets = read.empty() ? 0 : read[0]; if (num_targets != 1) { // no tags found or too many +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; @@ -181,11 +189,13 @@ void PN532::loop() { const uint8_t tag_type = tag_type_from_sel_res(sel_res); bool report = true; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *bin_sens : this->binary_sensors_) { if (bin_sens->process(nfcid)) { report = false; } } +#endif if (nfcid.size() == this->current_uid_.size()) { bool same_uid = true; @@ -199,8 +209,10 @@ void PN532::loop() { if (next_task_ == READ) { auto tag = this->read_tag_(nfcid, tag_type); +#ifdef PN532_ON_TAG_TRIGGER_COUNT for (auto *trigger : this->triggers_ontag_) trigger->process(tag); +#endif if (report) { char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; @@ -249,39 +261,42 @@ void PN532::loop() { this->turn_off_rf_(); } -bool PN532::write_command_(const std::vector &data) { - std::vector write_data; +bool PN532::write_command_(const std::span data) { + if (data.size() > PN532_FRAME_MAX_DATA_SIZE) { + return false; + } + PN532Frame frame; // Preamble - write_data.push_back(0x00); + frame.push_back(0x00); // Start code - write_data.push_back(0x00); - write_data.push_back(0xFF); + frame.push_back(0x00); + frame.push_back(0xFF); // Length of message, TFI + data bytes const uint8_t real_length = data.size() + 1; // LEN - write_data.push_back(real_length); + frame.push_back(real_length); // LCS (Length checksum) - write_data.push_back(~real_length + 1); + frame.push_back(~real_length + 1); // TFI (Frame Identifier, 0xD4 means to PN532, 0xD5 means from PN532) - write_data.push_back(0xD4); + frame.push_back(0xD4); // calculate checksum, TFI is part of checksum uint8_t checksum = 0xD4; // DATA for (uint8_t dat : data) { - write_data.push_back(dat); + frame.push_back(dat); checksum += dat; } // DCS (Data checksum) - write_data.push_back(~checksum + 1); + frame.push_back(~checksum + 1); // Postamble - write_data.push_back(0x00); + frame.push_back(0x00); - this->write_data(write_data); + this->write_data(frame); return this->read_ack_(); } @@ -289,8 +304,8 @@ bool PN532::write_command_(const std::vector &data) { bool PN532::read_ack_() { ESP_LOGV(TAG, "Reading ACK"); - std::vector data; - if (!this->read_data(data, 6)) { + PN532Frame data; + if (!this->read_data(data, 6) || data.size() < 7) { return false; } @@ -303,13 +318,15 @@ bool PN532::read_ack_() { } void PN532::send_ack_() { + static constexpr std::array ACK_FRAME = {0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; ESP_LOGV(TAG, "Sending ACK for abort"); - this->write_data({0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}); + this->write_data(ACK_FRAME); delay(10); } void PN532::send_nack_() { + static constexpr std::array NACK_FRAME = {0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}; ESP_LOGV(TAG, "Sending NACK for retransmit"); - this->write_data({0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}); + this->write_data(NACK_FRAME); delay(10); } @@ -425,7 +442,7 @@ bool PN532::write_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type, nfc::NdefMes return false; } -bool PN532::in_data_exchange_(const std::vector &command, std::vector &response) { +bool PN532::in_data_exchange_(const std::span command, PN532Frame &response) { // formatting a tag takes seconds of back-to-back exchanges inside loop(), longer than the task watchdog allows App.feed_wdt(); if (!this->write_command_(command)) { @@ -439,7 +456,25 @@ bool PN532::in_data_exchange_(const std::vector &command, std::vectorin_data_exchange_( + { + PN532_COMMAND_INDATAEXCHANGE, + 0x01, // One card + nfc::MIFARE_CMD_READ, + address, + }, + response) || + response.size() != data.size()) { + return false; + } + std::copy(response.begin(), response.end(), data.begin()); return true; } @@ -458,9 +493,11 @@ void PN532::dump_config() { LOG_UPDATE_INTERVAL(this); +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *child : this->binary_sensors_) { LOG_BINARY_SENSOR(" ", "Tag", child); } +#endif } bool PN532BinarySensor::process(const nfc::NfcTagUid &data) { diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 96013f7d796..5502eac6ec8 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -1,13 +1,16 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/nfc/nfc_tag.h" #include "esphome/components/nfc/nfc.h" #include "esphome/components/nfc/automation.h" +#include #include -#include +#include +#include namespace esphome::pn532 { @@ -40,6 +43,21 @@ inline uint8_t tag_type_from_sel_res(uint8_t sel_res) { return nfc::TAG_TYPE_UNKNOWN; } +/// Most data bytes a normal information frame carries: LEN is one byte and counts the TFI byte plus the data +static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; +/// A received frame at its longest: the status byte the I2C bus prepends, preamble, start code (2), LEN, LCS, TFI, +/// the data, DCS and postamble +static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; +/// Holds one frame in either direction so bus traffic never allocates +using PN532Frame = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages +static constexpr size_t MIFARE_READ_SIZE = 16; +using MifareReadData = std::array; + class PN532BinarySensor; class PN532 : public PollingComponent { @@ -53,9 +71,15 @@ class PN532 : public PollingComponent { void loop() override; void on_powerdown() override { powerdown(); } +#ifdef PN532_BINARY_SENSOR_COUNT void register_tag(PN532BinarySensor *tag) { this->binary_sensors_.push_back(tag); } +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif template void add_on_finished_write_callback(F &&callback) { this->on_finished_write_callback_.add(std::forward(callback)); @@ -71,16 +95,21 @@ class PN532 : public PollingComponent { protected: void turn_off_rf_(); - bool write_command_(const std::vector &data); + bool write_command_(std::span data); + bool write_command_(std::initializer_list data) { + return this->write_command_(std::span(data.begin(), data.size())); + } bool read_ack_(); void send_ack_(); void send_nack_(); enum PN532ReadReady read_ready_(bool block); virtual bool is_read_ready() = 0; - virtual bool write_data(const std::vector &data) = 0; - virtual bool read_data(std::vector &data, uint8_t len) = 0; - virtual bool read_response(uint8_t command, std::vector &data) = 0; + virtual bool write_data(std::span data) = 0; + /// Reads `len` frame bytes into `data` behind a leading status byte, so every bus presents the I2C layout + virtual bool read_data(PN532Frame &data, size_t len) = 0; + /// Reads the response to `command`; on success `data` holds only the bytes that follow the response code + virtual bool read_response(uint8_t command, PN532Frame &data) = 0; std::unique_ptr read_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); @@ -89,23 +118,28 @@ class PN532 : public PollingComponent { bool write_tag_(nfc::NfcTagUid &uid, uint8_t tag_type, nfc::NdefMessage *message); /// Sends an InDataExchange command and reads the response; returns false unless the status byte reports success. /// On success, `response` holds the data returned by the target, without the status byte. - bool in_data_exchange_(const std::vector &command, std::vector &response); + bool in_data_exchange_(std::span command, PN532Frame &response); + bool in_data_exchange_(std::initializer_list command, PN532Frame &response) { + return this->in_data_exchange_(std::span(command.begin(), command.size()), response); + } + /// Sends MIFARE READ for `address` and returns the 16 bytes the tag answers with + bool mifare_read_(uint8_t address, MifareReadData &data); std::unique_ptr read_mifare_classic_tag_(nfc::NfcTagUid &uid); - bool read_mifare_classic_block_(uint8_t block_num, std::vector &data); - bool write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); + bool read_mifare_classic_block_(uint8_t block_num, MifareReadData &data); + bool write_mifare_classic_block_(uint8_t block_num, std::span data); bool auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key); bool format_mifare_classic_mifare_(nfc::NfcTagUid &uid); bool format_mifare_classic_ndef_(nfc::NfcTagUid &uid); bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index); - bool write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); + bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); bool clean_mifare_ultralight_(); @@ -122,10 +156,16 @@ class PN532 : public PollingComponent { }; // members are ordered by alignment, widest first, to minimize padding - CallbackManager on_finished_write_callback_; - std::vector binary_sensors_; - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + LazyCallbackManager on_finished_write_callback_; +#ifdef PN532_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif std::unique_ptr next_task_message_to_write_; nfc::NfcTagUid current_uid_; uint32_t rd_start_time_{0}; // valid only while rd_started_ is set diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index 166f4ab7761..9f937b3e288 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include "pn532.h" @@ -13,10 +15,10 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid uint8_t message_start_index = 0; uint32_t message_length = 0; + MifareReadData block_data; if (this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { - std::vector data; - if (this->read_mifare_classic_block_(current_block, data)) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data)) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return make_unique(uid, nfc::ERROR); } } else { @@ -27,52 +29,50 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid ESP_LOGV(TAG, "Tag is not NDEF formatted"); return make_unique(uid, nfc::MIFARE_CLASSIC); } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (!this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { ESP_LOGE(TAG, "Error, Block authentication failed for %d", current_block); return make_unique(uid, nfc::MIFARE_CLASSIC); } } - std::vector block_data; if (!this->read_mifare_classic_block_(current_block, block_data)) { ESP_LOGE(TAG, "Error reading block %d", current_block); return make_unique(uid, nfc::MIFARE_CLASSIC); } - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + for (const uint8_t byte : block_data) { + buffer.push_back(byte); + } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return make_unique(uid, nfc::MIFARE_CLASSIC); } - return make_unique(uid, nfc::MIFARE_CLASSIC, buffer); + return make_unique( + uid, nfc::MIFARE_CLASSIC, + make_unique(std::span(buffer).subspan(message_start_index))); } -bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - if (!this->in_data_exchange_( - { - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - block_num, - }, - data) || - data.size() != nfc::MIFARE_CLASSIC_BLOCK_SIZE) { +bool PN532::read_mifare_classic_block_(uint8_t block_num, MifareReadData &data) { + if (!this->mifare_read_(block_num, data)) { return false; } @@ -82,20 +82,25 @@ bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector & } bool PN532::auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key) { - std::vector data({ + // InDataExchange, Tg, key slot, block, key (6), UID (4) + StaticVector data = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card key_num, // Mifare Key slot block_num, // Block number - }); - data.insert(data.end(), key, key + 6); + }; + for (size_t i = 0; i < 6; i++) { + data.push_back(key[i]); + } // the command takes exactly 4 UID bytes (UM0701-02, 7.3.8); for 7-byte UIDs these are the last 4, as in libnfc if (uid.size() < 4) { return false; } - data.insert(data.end(), uid.end() - 4, uid.end()); + for (size_t i = uid.size() - 4; i < uid.size(); i++) { + data.push_back(uid[i]); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(data, response)) { ESP_LOGE(TAG, "Authentication failed - Block 0x%02x", block_num); return false; @@ -117,20 +122,20 @@ bool PN532::format_mifare_classic_mifare_(nfc::NfcTagUid &uid) { continue; } if (block != 0) { - if (!this->write_mifare_classic_block_(block, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); error = true; } - if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER.data(), TRAILER_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 3); error = true; } @@ -157,11 +162,11 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { ESP_LOGE(TAG, "Unable to authenticate block 0 for formatting!"); return false; } - if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA.data(), BLOCK_1_DATA.size())) + if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA)) return false; - if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA.data(), BLOCK_2_DATA.size())) + if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA)) return false; - if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER.data(), BLOCK_3_TRAILER.size())) + if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER)) return false; ESP_LOGD(TAG, "Sector 0 formatted to NDEF"); @@ -173,25 +178,25 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { return false; } if (block == 4) { - if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size())) { + if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } else { - if (!this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); error = true; } - if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size())) { + if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER)) { ESP_LOGE(TAG, "Unable to write trailer block %d", block + 3); error = true; } @@ -199,16 +204,18 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { return !error; } -bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_classic_block_(uint8_t block_num, const std::span data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE, block_num, - }); - cmd.insert(cmd.end(), data, data + len); + }; + for (const uint8_t byte : data) { + cmd.push_back(byte); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing block %d", block_num); return false; @@ -218,22 +225,10 @@ bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, } bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -245,7 +240,8 @@ bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *mes } } - if (!this->write_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE)) { + if (!this->write_mifare_classic_block_(current_block, + std::span(&buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE))) { return false; } index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index d918c9d52d2..90c85237224 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -10,7 +10,7 @@ namespace esphome::pn532 { static const char *const TAG = "pn532.mifare_ultralight"; std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - std::vector data; + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data)) { @@ -41,31 +41,30 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } - return make_unique(uid, nfc::NFC_FORUM_TYPE_2, data); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2, + make_unique(std::span(data).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { - const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - std::vector response; +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { + MifareReadData chunk; - for (uint8_t i = 0; i * read_increment < num_bytes; i++) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { // a READ returns 4 pages (16 bytes) - if (!this->in_data_exchange_( - { - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), - }, - response) || - response.size() != read_increment) { + if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } - const uint16_t remaining = num_bytes - i * read_increment; - data.insert(data.end(), response.begin(), response.begin() + std::min(read_increment, remaining)); + // keep only the bytes still wanted from this read + const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, remaining); + for (const uint8_t byte : std::span(chunk).subspan(0, count)) { + data.push_back(byte); + } } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -74,7 +73,7 @@ bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes return true; } -bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN532::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -83,7 +82,7 @@ bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_t } uint16_t PN532::read_mifare_ultralight_capacity_() { - std::vector data; + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -91,7 +90,7 @@ uint16_t PN532::read_mifare_ultralight_capacity_() { return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -114,33 +113,23 @@ bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6 bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return false; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (!this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE)) { + if (!this->write_mifare_ultralight_page_( + current_page, std::span(&buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE))) { return false; } index += nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; @@ -156,23 +145,25 @@ bool PN532::clean_mifare_ultralight_() { static constexpr std::array BLANK_DATA = {0x00, 0x00, 0x00, 0x00}; for (int i = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; i < pages; i++) { - if (!this->write_mifare_ultralight_page_(i, BLANK_DATA.data(), BLANK_DATA.size())) { + if (!this->write_mifare_ultralight_page_(i, BLANK_DATA)) { return false; } } return true; } -bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const std::span write_data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num, - }); - cmd.insert(cmd.end(), write_data, write_data + len); + }; + for (const uint8_t byte : write_data) { + cmd.push_back(byte); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing page %u", page_num); return false; diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 4160076a7de..7f00e18d4c0 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -20,11 +22,14 @@ bool PN532I2C::is_read_ready() { return status & 0x01; } -bool PN532I2C::write_data(const std::vector &data) { +bool PN532I2C::write_data(const std::span data) { return this->write(data.data(), data.size()) == i2c::ERROR_OK; } -bool PN532I2C::read_data(std::vector &data, uint8_t len) { +bool PN532I2C::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE) { + return false; + } delay(1); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { @@ -36,7 +41,7 @@ bool PN532I2C::read_data(std::vector &data, uint8_t len) { return this->read_bytes_raw(data.data(), len + 1); } -bool PN532I2C::read_response(uint8_t command, std::vector &data) { +bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); uint8_t len = this->read_response_length_(); if (len == 0) { @@ -64,33 +69,33 @@ bool PN532I2C::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.begin(), data.begin() + 6); // Remove headers - + // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble + constexpr size_t tfi_offset = 6; uint8_t checksum = 0; - for (int i = 0; i < len + 1; i++) { - uint8_t dat = data[i]; - checksum += dat; + for (size_t i = 0; i < len + 1U; i++) { + checksum += data[tfi_offset + i]; } checksum = ~checksum + 1; - if (data[len + 1] != checksum) { - ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len + 1], checksum); + if (data[tfi_offset + len + 1] != checksum) { + ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); return false; } - if (data[len + 2] != 0x00) { + if (data[tfi_offset + len + 2] != 0x00) { ESP_LOGV(TAG, "read data invalid postamble!"); return false; } - data.erase(data.begin(), data.begin() + 2); // Remove TFI and command code - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + // keep only the data bytes that follow the command response code + std::copy(data.begin() + tfi_offset + 2, data.begin() + tfi_offset + len + 1, data.begin()); + data.resize(len - 1); return true; } uint8_t PN532I2C::read_response_length_() { - std::vector data; + pn532::PN532Frame data; if (!this->read_data(data, 6)) { return 0; } diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index 6495f175999..f148e196a02 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/i2c/i2c.h" -#include +#include namespace esphome::pn532_i2c { @@ -14,9 +14,9 @@ class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; uint8_t read_response_length_(); }; diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 112adc4ecb8..73d65e4330d 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -2,6 +2,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -31,7 +33,7 @@ bool PN532Spi::is_read_ready() { return ready; } -bool PN532Spi::write_data(const std::vector &data) { +bool PN532Spi::write_data(const std::span data) { this->enable(); delay(2); // First byte, communication mode: Write data @@ -46,8 +48,8 @@ bool PN532Spi::write_data(const std::vector &data) { return true; } -bool PN532Spi::read_data(std::vector &data, uint8_t len) { - if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { +bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE || this->read_ready_(true) != pn532::PN532ReadReady::READY) { return false; } @@ -58,10 +60,11 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { ESP_LOGV(TAG, "Reading data"); - data.resize(len); - this->read_array(data.data(), len); + // lead with a status byte so callers see the same layout as on the I2C bus + data.resize(len + 1); + data[0] = 0x01; + this->read_array(data.data() + 1, len); this->disable(); - data.insert(data.begin(), 0x01); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; #endif @@ -69,7 +72,7 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { return true; } -bool PN532Spi::read_response(uint8_t command, std::vector &data) { +bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { @@ -80,8 +83,8 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { delay(2); this->write_byte(0x03); - std::vector header(7); - this->read_array(header.data(), 7); + std::array header; + this->read_array(header.data(), header.size()); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; @@ -141,7 +144,7 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + data.resize(len - 1); // Remove checksum and postamble return true; } diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index f29950c423c..b051cd781e5 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/spi/spi.h" -#include +#include namespace esphome::pn532_spi { @@ -18,9 +18,9 @@ class PN532Spi final : public pn532::PN532, protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; }; } // namespace esphome::pn532_spi diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 19b3f475be1..f768777c041 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -139,6 +139,9 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define PN532_BINARY_SENSOR_COUNT 1 +#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN532_ON_TAG_TRIGGER_COUNT 1 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 13ba0a9791c..6d00e187999 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -276,6 +276,9 @@ template class StaticVector { // Clear all elements void clear() { count_ = 0; } + // Set the element count, capped at N. Elements are neither initialized when growing nor destroyed when + // shrinking; release owning elements before shrinking past them. + void resize(size_t n) { count_ = n < N ? n : N; } // Assign from iterator range template void assign(InputIt first, InputIt last) { diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp index 38c369acbc0..75f9cf6d088 100644 --- a/tests/components/pn532/pn532_test.cpp +++ b/tests/components/pn532/pn532_test.cpp @@ -1,6 +1,8 @@ #include +#include #include +#include #include "esphome/components/pn532/pn532.h" @@ -14,6 +16,7 @@ class FakePN532 : public PN532 { using PN532::auth_mifare_classic_block_; using PN532::read_mifare_ultralight_bytes_; using PN532::read_mifare_classic_block_; + using PN532::write_command_; using PN532::write_mifare_classic_block_; using PN532::write_mifare_ultralight_page_; @@ -22,24 +25,26 @@ class FakePN532 : public PN532 { protected: bool is_read_ready() override { return true; } - bool write_data(const std::vector &data) override { - this->written.push_back(data); + bool write_data(std::span data) override { + this->written.emplace_back(data.begin(), data.end()); return true; } // only used for ACK frames; index 0 is the I2C status byte - bool read_data(std::vector &data, uint8_t len) override { + bool read_data(PN532Frame &data, size_t len) override { data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; return true; } - bool read_response(uint8_t command, std::vector &data) override { + bool read_response(uint8_t command, PN532Frame &data) override { if (this->responses.empty()) return false; - data = this->responses.front(); + data.assign(this->responses.front().begin(), this->responses.front().end()); this->responses.pop_front(); return true; } }; +std::vector bytes_of(std::span bytes) { return {bytes.begin(), bytes.end()}; } + // Extracts the command bytes (after TFI) from a normal information frame std::vector frame_data(const std::vector &frame) { // preamble, start code (2), LEN, LCS, TFI, data..., DCS, postamble @@ -58,40 +63,79 @@ TEST(PN532TagType, FromSelRes) { EXPECT_EQ(tag_type_from_sel_res(0x40), nfc::TAG_TYPE_UNKNOWN); } +// The frame wraps the command in preamble, start code, LEN, LCS, TFI, DCS and postamble (UM0701-02, 6.2.1.1). +TEST(PN532Frame, WrapsCommand) { + FakePN532 pn532; + ASSERT_TRUE(pn532.write_command_({0x4A, 0x01, 0x00})); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0], (std::vector{0x00, 0x00, 0xFF, 0x04, 0xFC, 0xD4, 0x4A, 0x01, 0x00, 0xE1, 0x00})); +} + +// A command that cannot fit a normal information frame is refused rather than truncated. +TEST(PN532Frame, RejectsOversizedCommand) { + FakePN532 pn532; + std::array too_long{}; + EXPECT_FALSE(pn532.write_command_(too_long)); + EXPECT_TRUE(pn532.written.empty()); + EXPECT_TRUE(pn532.write_command_(std::span(too_long).first(PN532_FRAME_MAX_DATA_SIZE))); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0].size(), PN532_FRAME_MAX_DATA_SIZE + 8); +} + // A failed write (status byte other than 0x00) must be reported as a failure. TEST(PN532Mifare, ClassicWriteChecksStatus) { FakePN532 pn532; const uint8_t block[16] = {}; pn532.responses.push_back({0x14}); // authentication error - EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); + EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block)); pn532.responses.push_back({0x00}); - EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); + EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block)); } TEST(PN532Mifare, UltralightWriteChecksStatus) { FakePN532 pn532; const uint8_t page[4] = {}; pn532.responses.push_back({0x01}); // timeout - EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); + EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page)); pn532.responses.push_back({0x00}); - EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); + EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page)); } TEST(PN532Mifare, ClassicReadRejectsBadResponses) { FakePN532 pn532; - std::vector data; + MifareReadData data{}; pn532.responses.emplace_back(); // empty response EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); - data.clear(); pn532.responses.push_back({0x00, 0x01, 0x02}); // short block EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); std::vector good(17, 0xAB); good[0] = 0x00; pn532.responses.push_back(good); - data.clear(); EXPECT_TRUE(pn532.read_mifare_classic_block_(4, data)); - EXPECT_EQ(data, std::vector(16, 0xAB)); + EXPECT_EQ(bytes_of(data), std::vector(16, 0xAB)); +} + +// The NDEF TLV is type 0x03, a one-byte length below 255 (three bytes otherwise), the message, terminator 0xFE, +// then zero padding out to the requested length. +TEST(PN532Ndef, FillsTlv) { + FixedVector buffer; + const std::array message = {0xD1, 0x01, 0x02}; + nfc::fill_ndef_tlv(message, 8, buffer); + EXPECT_EQ(bytes_of(std::span(buffer)), + (std::vector{0x03, 0x03, 0xD1, 0x01, 0x02, 0xFE, 0x00, 0x00})); + + std::vector long_message(300, 0xAA); + nfc::fill_ndef_tlv(long_message, 320, buffer); + ASSERT_EQ(buffer.size(), 320u); + EXPECT_EQ(buffer[0], 0x03); + EXPECT_EQ(buffer[1], 0xFF); + EXPECT_EQ(buffer[2], 0x01); // 300 = 0x012C + EXPECT_EQ(buffer[3], 0x2C); + EXPECT_EQ(buffer[4], 0xAA); + EXPECT_EQ(buffer[303], 0xAA); + EXPECT_EQ(buffer[304], 0xFE); + EXPECT_EQ(buffer[319], 0x00); } // Authentication carries exactly 4 UID bytes: the last 4 of a 7-byte UID. @@ -119,7 +163,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { pn532.responses.push_back(first); pn532.responses.push_back(second); - std::vector data; + UltralightReadBuffer data; ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); ASSERT_EQ(data.size(), 20u); EXPECT_EQ(data[15], 15); @@ -133,7 +177,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { TEST(PN532Mifare, UltralightReadRejectsBadResponses) { FakePN532 pn532; - std::vector data; + UltralightReadBuffer data; pn532.responses.push_back({0x00, 0x01, 0x02}); // short response EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); From c26f2d28e64197f3d5da0442ebf0cb1a4276378c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:48:36 +0200 Subject: [PATCH 590/759] [hoermann_hcp] Add a door state text sensor (#19747) --- .../components/hoermann_hcp/hoermann_hcp.cpp | 22 ++- .../components/hoermann_hcp/hoermann_hcp.h | 14 +- .../hoermann_hcp/text_sensor/__init__.py | 19 ++- .../text_sensor/hoermann_hcp_text_sensor.cpp | 49 +++++++ .../text_sensor/hoermann_hcp_text_sensor.h | 24 ++++ esphome/core/defines.h | 2 +- tests/components/hoermann_hcp/common.h | 2 +- tests/components/hoermann_hcp/common.yaml | 2 + .../hoermann_hcp/text_sensor/__init__.py | 2 +- .../hoermann_hcp_door_state_test.cpp | 125 ++++++++++++++++++ 10 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp create mode 100644 esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h create mode 100644 tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 261996e5570..9ab7014e0e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -63,7 +63,7 @@ static bool is_moving(DoorState state) { } } -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY // The command byte of a status poll. Only its answer can carry a request. static constexpr uint8_t STATUS_COMMAND = 0x03; // A status answer with this code in the low byte of its second register asks the bus controller for a value, @@ -149,7 +149,7 @@ void HoermannHcp::update() { ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); this->forget_light_toggles_(); } -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY this->publish_identity_(); #endif if (this->changed_) { @@ -163,7 +163,7 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); log_identity_value(this->serial_number_text_sensor_); LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_); @@ -180,7 +180,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add this->record_response_(); -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY // Acknowledge the transfer taken by the write half of this frame. if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) { this->push_transfer_answer_(registers, number_of_registers); @@ -200,7 +200,7 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add registers.push_back(static_cast(0x0001 | command)); this->push_command_registers_(registers); push_zeros(registers, 4); -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY this->add_identity_request_(registers, command); #endif break; @@ -234,7 +234,7 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, // command byte back from STATE_REG. The hub always runs the write before the read within one request. this->record_response_(); this->command_reg_value_ = registers[0]; -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY this->transfer_answer_counter_ = this->take_identity_transfer_(registers); #endif return {}; @@ -298,7 +298,7 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { registers.push_back(command->released_value_2); } -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY void HoermannHcp::add_identity_request_(modbus::RegisterValues ®isters, uint16_t command) { if (static_cast(this->command_reg_value_) != STATUS_COMMAND) return; @@ -601,6 +601,9 @@ void HoermannHcp::set_valid_(bool valid) { 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); + // The same holds for the door. The next broadcast is decoded even if it repeats the last one. + this->door_state_seen_ = false; + this->prev_state_reg_ = 0xFFFF; this->short_broadcast_logged_ = false; } @@ -640,6 +643,11 @@ void HoermannHcp::forget_light_toggles_() { } void HoermannHcp::set_door_state_(DoorState state) { + // The first state may equal the default, so being seen is a change of its own. + if (!this->door_state_seen_) { + this->door_state_seen_ = true; + this->changed_ = true; + } if (this->door_state_ == state) return; this->door_state_ = state; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index fd52a689d0a..9ebb717d0c4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -6,7 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -25,7 +25,7 @@ enum class DoorState : uint8_t { STOPPED, }; -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY // Payload registers of each value, two bytes each. static constexpr size_t SERIAL_FIRST_HALF_REGS = 7; static constexpr size_t SERIAL_SECOND_HALF_REGS = 6; @@ -61,7 +61,7 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY // The motor is asked for these only when one of them is configured. SUB_TEXT_SENSOR(serial_number) SUB_TEXT_SENSOR(version) @@ -95,6 +95,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } + // False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the + // connection valid, so get_door_state() would still be its default. + bool is_door_state_known() const { return this->door_state_seen_; } 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_; } @@ -127,7 +130,7 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); void on_light_reg_(uint16_t value); -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY // Puts a due request into a status answer. void add_identity_request_(modbus::RegisterValues ®isters, uint16_t command); void arm_identity_request_(IdentityPhase phase); @@ -192,9 +195,10 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool changed_{false}; bool light_on_{false}; bool light_seen_{false}; + bool door_state_seen_{false}; bool short_broadcast_logged_{false}; -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY uint32_t identity_asked_at_{0}; IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE}; uint8_t identity_attempts_{0}; diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index 0fcbe600403..2f038025b9a 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -4,16 +4,24 @@ 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_HOERMANN_HCP_ID, HoermannHcp +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] +CONF_DOOR_STATE = "door_state" CONF_SERIAL_NUMBER = "serial_number" +HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( + "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_DOOR_STATE): text_sensor.text_sensor_schema( + HoermannHcpDoorStateTextSensor, icon="mdi:garage" + ).extend(cv.COMPONENT_SCHEMA), cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), @@ -22,13 +30,18 @@ CONFIG_SCHEMA = cv.All( ), } ), - cv.has_at_least_one_key(CONF_SERIAL_NUMBER, CONF_VERSION), + cv.has_at_least_one_key(CONF_DOOR_STATE, CONF_SERIAL_NUMBER, CONF_VERSION), ) async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) - cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + if (conf := config.get(CONF_DOOR_STATE)) is not None: + var = await text_sensor.new_text_sensor(conf, parent) + await cg.register_component(var, conf) + # Only the identity sensors need the exchange with the motor compiled in. + if CONF_SERIAL_NUMBER in config or CONF_VERSION in config: + cg.add_define("USE_HOERMANN_HCP_IDENTITY") if (conf := config.get(CONF_SERIAL_NUMBER)) is not None: sens = await text_sensor.new_text_sensor(conf) cg.add(parent.set_serial_number_text_sensor(sens)) diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp new file mode 100644 index 00000000000..765a80ae6f3 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp @@ -0,0 +1,49 @@ +#include "hoermann_hcp_text_sensor.h" + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.text_sensor"; + +// Indexed by DoorState. Each fits the 15 characters std::string keeps inline, so publishing never allocates. +PROGMEM_STRING_TABLE(DoorStateStrings, "Open", "Opening", "Closed", "Closing", "Half open", "Moving to vent", + "Vent position", "Moving to half", "Stopped"); +static_assert(DoorStateStrings::COUNT == static_cast(DoorState::STOPPED) + 1, + "DoorStateStrings must list every DoorState in order"); + +void HoermannHcpDoorStateTextSensor::setup() { + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); + // A state decoded before the callback was added would otherwise wait for the next change. + this->update_from_state_(); +} + +void HoermannHcpDoorStateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Hoermann HCP Door State", this); } + +void HoermannHcpDoorStateTextSensor::update_from_state_() { + // The last state stays while the bus controller is gone, and is published again once it is back. + if (!this->parent_->is_valid()) { + this->published_ = false; + return; + } + if (!this->parent_->is_door_state_known()) + return; + // Any hub change runs this, so only a new door state is published. + const DoorState state = this->parent_->get_door_state(); + if (this->published_ && state == this->published_state_) + return; + this->published_ = true; + this->published_state_ = state; + char text[16]; + ESPHOME_strncpy_P(text, + reinterpret_cast( + DoorStateStrings::get_progmem_str(static_cast(state), DoorStateStrings::LAST_INDEX)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h new file mode 100644 index 00000000000..a3fb91cd9a8 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpDoorStateTextSensor : public text_sensor::TextSensor, public Component { + public: + explicit HoermannHcpDoorStateTextSensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + void update_from_state_(); + + HoermannHcp *const parent_; + DoorState published_state_{DoorState::CLOSED}; + bool published_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f768777c041..29b686e03c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -77,7 +77,7 @@ #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU -#define USE_HOERMANN_HCP_TEXT_SENSOR +#define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index fb940d790fa..bc9975f2e8b 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -66,7 +66,7 @@ class TestableHoermannHcp : public HoermannHcp { TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } using HoermannHcp::connection_timeout_ms_; -#ifdef USE_HOERMANN_HCP_TEXT_SENSOR +#ifdef USE_HOERMANN_HCP_IDENTITY using HoermannHcp::identity_asked_at_; using HoermannHcp::identity_request_; using HoermannHcp::firmware_unreadable_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 394afed3603..21d3d2b8485 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -25,6 +25,8 @@ light: text_sensor: - platform: hoermann_hcp + door_state: + name: Garage Door State serial_number: name: Garage Motor Serial Number version: diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py index 693c48b736f..20e71245071 100644 --- a/tests/components/hoermann_hcp/text_sensor/__init__.py +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -6,6 +6,6 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: - cg.add_define("USE_HOERMANN_HCP_TEXT_SENSOR") + cg.add_define("USE_HOERMANN_HCP_IDENTITY") manifest.to_code = to_code_testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp new file mode 100644 index 00000000000..dfb3ba0e92d --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp @@ -0,0 +1,125 @@ +#include + +#include "esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// A status broadcast with the door state in the high byte of its third register. +void broadcast_state(HoermannHcp &door, uint16_t state_reg) { + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, state_reg})); + door.update(); +} + +struct DoorStateFixture { + DoorStateFixture() { + this->sensor.setup(); + this->sensor.add_on_state_callback([this](const std::string & /*state*/) { this->publishes++; }); + } + + TestableHoermannHcp door; + HoermannHcpDoorStateTextSensor sensor{&door}; + int publishes{0}; +}; + +} // namespace + +// Polls and bus scans make the connection valid before any broadcast has said where the door is, and an +// undecodable state says nothing either. None of these may show the default state. +TEST(HoermannHcpDoorStateTest, NothingBeforeTheDoorReportsAState) { + DoorStateFixture fixture; + auto &door = fixture.door; + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + status_answer(door, 0x0003); + door.update(); + ASSERT_TRUE(door.is_valid()); + EXPECT_FALSE(fixture.sensor.has_state()); + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + broadcast_state(door, 0x1000); + EXPECT_FALSE(fixture.sensor.has_state()); + + // The first real state is shown even when it equals the default. + broadcast_state(door, 0x4000); + EXPECT_EQ(fixture.sensor.get_state(), "Closed"); +} + +// Every state the door reports is shown, including the vent and half-open positions and the moves to them. +TEST(HoermannHcpDoorStateTest, FollowsTheDoorState) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + + const std::pair states[] = { + {0x2000, "Open"}, {0x0200, "Closing"}, {0x4000, "Closed"}, {0x0900, "Moving to vent"}, + {0x0A00, "Vent position"}, {0x0500, "Moving to half"}, {0x8000, "Half open"}, {0x0100, "Opening"}, + {0x0000, "Stopped"}, {0x0061, "Vent position"}, + }; + for (const auto &[reg, text] : states) { + broadcast_state(door, reg); + EXPECT_EQ(fixture.sensor.get_state(), text) << "state register 0x" << std::hex << reg; + } +} + +// Any hub change runs the publish path, so an unchanged door state is not published again, and a state the +// door is not known to report keeps the last one. +TEST(HoermannHcpDoorStateTest, EachStateIsPublishedOnce) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x2000); + ASSERT_EQ(fixture.publishes, 1); + + // The lamp changes, the door state does not. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x2000, 0x0000, 0x0000, 0x0000, 0x0010})); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x1000); + EXPECT_EQ(fixture.publishes, 1); + EXPECT_EQ(fixture.sensor.get_state(), "Open"); +} + +// While the bus controller is gone the last state stays. Once it is back, a poll alone shows nothing new; the +// next broadcast is published again even if it repeats the old state. +TEST(HoermannHcpDoorStateTest, LastStateStaysUntilTheNextBroadcast) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x8000); + ASSERT_EQ(fixture.publishes, 1); + + door.set_valid_(false); + door.update(); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); + + connect_controller(door); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x8000); + EXPECT_EQ(fixture.publishes, 2); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); +} + +// A sensor set up after the hub already decoded a state shows it right away. +TEST(HoermannHcpDoorStateTest, LateSetupShowsTheCurrentState) { + TestableHoermannHcp door; + connect_controller(door); + broadcast_state(door, 0x0A00); + + HoermannHcpDoorStateTextSensor sensor(&door); + sensor.setup(); + EXPECT_EQ(sensor.get_state(), "Vent position"); +} + +} // namespace esphome::hoermann_hcp::testing From b8af6cb005d1195d8a7acca4e1ad7bc7ebc05096 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:25:54 +1000 Subject: [PATCH 591/759] [counter] Add persistent counter sensor (#19746) --- CODEOWNERS | 1 + esphome/components/counter/__init__.py | 1 + esphome/components/counter/counter_sensor.cpp | 28 ++++++ esphome/components/counter/counter_sensor.h | 47 +++++++++ esphome/components/counter/sensor.py | 85 ++++++++++++++++ tests/component_tests/counter/__init__.py | 0 .../counter/config/counter_single.yaml | 17 ++++ .../counter/config/counter_test.yaml | 42 ++++++++ tests/component_tests/counter/test_counter.py | 99 +++++++++++++++++++ tests/components/counter/common.h | 30 ++++++ tests/components/counter/common.yaml | 37 +++++++ .../counter/sensor/test_counter.cpp | 84 ++++++++++++++++ tests/components/counter/test.esp32-idf.yaml | 2 + .../counter/validate.esp32-idf.yaml | 12 +++ 14 files changed, 485 insertions(+) create mode 100644 esphome/components/counter/__init__.py create mode 100644 esphome/components/counter/counter_sensor.cpp create mode 100644 esphome/components/counter/counter_sensor.h create mode 100644 esphome/components/counter/sensor.py create mode 100644 tests/component_tests/counter/__init__.py create mode 100644 tests/component_tests/counter/config/counter_single.yaml create mode 100644 tests/component_tests/counter/config/counter_test.yaml create mode 100644 tests/component_tests/counter/test_counter.py create mode 100644 tests/components/counter/common.h create mode 100644 tests/components/counter/common.yaml create mode 100644 tests/components/counter/sensor/test_counter.cpp create mode 100644 tests/components/counter/test.esp32-idf.yaml create mode 100644 tests/components/counter/validate.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index eaf8c795767..ff2b6eb3275 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -125,6 +125,7 @@ esphome/components/combination/* @Cat-Ion @kahrendt esphome/components/const/* @esphome/core esphome/components/coolix/* @glmnet esphome/components/copy/* @OttoWinter +esphome/components/counter/* @clydebarrow esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/counter/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp new file mode 100644 index 00000000000..22adb49cca7 --- /dev/null +++ b/esphome/components/counter/counter_sensor.cpp @@ -0,0 +1,28 @@ +#include "counter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::counter { + +static const char *const TAG = "counter"; + +void CounterSensor::setup() { + if (this->restore_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->value_); + } + this->publish_state(static_cast(this->value_)); +} + +void CounterSensor::set_value(int64_t value) { + this->value_ = value; + this->publish_state(static_cast(value)); + if (this->restore_) + this->pref_.save(&this->value_); +} + +void CounterSensor::dump_config() { + LOG_SENSOR("", "Counter", this); + ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_)); +} + +} // namespace esphome::counter diff --git a/esphome/components/counter/counter_sensor.h b/esphome/components/counter/counter_sensor.h new file mode 100644 index 00000000000..3468c4d5a04 --- /dev/null +++ b/esphome/components/counter/counter_sensor.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::counter { + +/// A counter held as a 64-bit integer. The published sensor state is a float, so it is exact only up to 2^24. +class CounterSensor final : public sensor::Sensor, public Component { + public: + /// The counter starts at initial_value unless a stored value is restored. + CounterSensor(bool restore, int64_t initial_value) : value_(initial_value), restore_(restore) {} + + void setup() override; + void dump_config() override; + // restore value before `on_boot` automations run + float get_setup_priority() const override { return setup_priority::DATA + 50.0f; } + + /// Increment by one each time the given sensor publishes a state. + void count_updates_from(sensor::Sensor *source) { + source->add_on_state_callback([this](float) { this->increment(); }); + } + + /// Increment by one each time the given binary sensor changes to true. + template void count_true_from(T *source) { + source->add_on_state_callback([this](bool state) { + if (state) + this->increment(); + }); + } + + void set_value(int64_t value); + /// overflow on addition of signed numbers is undefined - use the well defined unsigned version + void increment(int64_t amount = 1) { + this->set_value(static_cast(static_cast(this->value_) + static_cast(amount))); + } + + protected: + ESPPreferenceObject pref_; + int64_t value_; + bool restore_; +}; + +} // namespace esphome::counter diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py new file mode 100644 index 00000000000..0f36b1721bf --- /dev/null +++ b/esphome/components/counter/sensor.py @@ -0,0 +1,85 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BINARY_SENSOR, + CONF_ID, + CONF_INITIAL_VALUE, + CONF_RESTORE, + CONF_SENSOR, + CONF_VALUE, + ICON_COUNTER, +) +from esphome.types import ConfigType + +counter_ns = cg.esphome_ns.namespace("counter") +CounterSensor = counter_ns.class_("CounterSensor", sensor.Sensor, cg.Component) + +# The lowest value is left out because its C++ literal cannot be written portably. +INT64_MAX = 2**63 - 1 +COUNTER_RANGE = cv.int_range(min=-INT64_MAX, max=INT64_MAX) +COUNTER_VALUE = cv.templatable(COUNTER_RANGE) + + +def _not_own_source(config: ConfigType) -> ConfigType: + if (source := config.get(CONF_SENSOR)) is not None and source.id == config[ + CONF_ID + ].id: + raise cv.Invalid("A counter cannot count its own updates", [CONF_SENSOR]) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + CounterSensor, + icon=ICON_COUNTER, + accuracy_decimals=0, + ) + .extend( + { + cv.Optional(CONF_RESTORE, default=True): cv.boolean, + cv.Optional(CONF_INITIAL_VALUE, default=0): COUNTER_RANGE, + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor), + } + ) + .extend(cv.COMPONENT_SCHEMA), + _not_own_source, +) + + +async def to_code(config): + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_RESTORE], config[CONF_INITIAL_VALUE] + ) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + if (source := config.get(CONF_SENSOR)) is not None: + cg.add(var.count_updates_from(await cg.get_variable(source))) + if (source := config.get(CONF_BINARY_SENSOR)) is not None: + cg.add(var.count_true_from(await cg.get_variable(source))) + + +automation.register_apply_action( + "counter.set_value", + cv.maybe_simple_value( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Required(CONF_VALUE): COUNTER_VALUE, + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, "set_value", cg.int64), +) + +automation.register_apply_action( + "counter.increment", + automation.maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Optional(CONF_VALUE, default=1): COUNTER_VALUE, + } + ), + automation.ApplyField(CONF_VALUE, "increment", cg.int64), +) diff --git a/tests/component_tests/counter/__init__.py b/tests/component_tests/counter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/counter/config/counter_single.yaml b/tests/component_tests/counter/config/counter_single.yaml new file mode 100644 index 00000000000..41b7f55233d --- /dev/null +++ b/tests/component_tests/counter/config/counter_single.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: 5 + - counter.set_value: + value: 7 + - counter.increment: + value: -3 + +esp32: + board: esp32dev + +sensor: + - platform: counter + id: only_counter + name: Only Counter diff --git a/tests/component_tests/counter/config/counter_test.yaml b/tests/component_tests/counter/config/counter_test.yaml new file mode 100644 index 00000000000..b149df42b72 --- /dev/null +++ b/tests/component_tests/counter/config/counter_test.yaml @@ -0,0 +1,42 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: + id: counter_a + value: 100 + - counter.set_value: + id: counter_b + value: -5000000000 + - counter.increment: counter_a + - counter.increment: + id: counter_a + value: -7 + - counter.increment: + id: counter_b + value: !lambda "return 3;" + +esp32: + board: esp32dev + +sensor: + - platform: template + id: source_sensor + lambda: return 1.0; + - platform: counter + id: counter_a + name: Counter A + sensor: source_sensor + - platform: counter + id: counter_b + name: Counter B + restore: false + initial_value: -5000000000 + - platform: counter + id: counter_c + name: Counter C + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/component_tests/counter/test_counter.py b/tests/component_tests/counter/test_counter.py new file mode 100644 index 00000000000..454f023af1d --- /dev/null +++ b/tests/component_tests/counter/test_counter.py @@ -0,0 +1,99 @@ +"""Tests for the counter sensor.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE + +INT64_MAX = 2**63 - 1 + + +def test_counter_constructor_arguments( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Restore (on by default) and initial value (zero by default) are constructor arguments.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp + assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp + + +def test_counter_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a sensor option counts that sensor's updates.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_a->count_updates_from(source_sensor);" in main_cpp + assert main_cpp.count("count_updates_from") == 1 + + +def test_counter_binary_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a binary_sensor option counts that sensor's changes to true.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp + assert main_cpp.count("count_true_from") == 1 + + +def test_counter_actions( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Values reach the counter as 64-bit integers; increment defaults to one.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "::counter_a->set_value(100);" in main_cpp + assert "::counter_b->set_value(-5000000000LL);" in main_cpp + assert "::counter_a->increment(1);" in main_cpp + assert "::counter_a->increment(-7);" in main_cpp + assert "::counter_b->increment(static_cast(3));" in main_cpp + + +def test_counter_actions_without_id_use_only_counter( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With one counter configured, the id may be left out of an action.""" + main_cpp = generate_main(component_config_path("counter_single.yaml")) + + assert "::only_counter->set_value(5);" in main_cpp + assert "::only_counter->set_value(7);" in main_cpp + assert "::only_counter->increment(-3);" in main_cpp + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_initial_value_must_be_int64(value: float) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value}) + + +def test_counter_cannot_count_itself() -> None: + """A counter watching its own updates would recurse forever.""" + with pytest.raises(cv.Invalid, match="cannot count its own updates"): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"}) + + +def test_counter_accepts_other_source() -> None: + config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"}) + + assert config["sensor"].id == "other" + + +@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX]) +def test_counter_value_accepts_int64_range(value: int) -> None: + assert COUNTER_VALUE(value) == value + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_value_rejects_out_of_range(value: float) -> None: + with pytest.raises(cv.Invalid): + COUNTER_VALUE(value) diff --git a/tests/components/counter/common.h b/tests/components/counter/common.h new file mode 100644 index 00000000000..ac9cf7f5a98 --- /dev/null +++ b/tests/components/counter/common.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/components/counter/counter_sensor.h" + +namespace esphome::counter::testing { + +constexpr int64_t INT64_MAX_VALUE = std::numeric_limits::max(); +constexpr int64_t INT64_MIN_VALUE = std::numeric_limits::min(); + +/// Stands in for a binary sensor: reports each state to its callbacks. +struct FakeBinarySource { + template void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward(callback)); } + void publish(bool state) { this->callbacks_.call(state); } + CallbackManager callbacks_; +}; + +// Restore is off so no preference storage is needed. +class CounterTest : public ::testing::Test { + protected: + CounterSensor counter_{false, 0}; +}; + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/common.yaml b/tests/components/counter/common.yaml new file mode 100644 index 00000000000..6a0d8aecc8b --- /dev/null +++ b/tests/components/counter/common.yaml @@ -0,0 +1,37 @@ +esphome: + on_boot: + then: + - counter.set_value: + id: counter_sensor + value: 100 + - counter.increment: counter_sensor + - counter.increment: + id: counter_sensor + value: -5 + - counter.increment: + id: counter_sensor + value: !lambda "return 5000000000LL;" + +sensor: + - platform: counter + id: counter_sensor + name: Counter + - platform: counter + id: counter_no_restore + name: Counter No Restore + restore: false + initial_value: 10 + - platform: template + id: source_sensor + lambda: return 1.0; + update_interval: 1s + - platform: counter + name: Counter Of Sensor + sensor: source_sensor + - platform: counter + name: Counter Of Binary Sensor + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/components/counter/sensor/test_counter.cpp b/tests/components/counter/sensor/test_counter.cpp new file mode 100644 index 00000000000..a0431062e8d --- /dev/null +++ b/tests/components/counter/sensor/test_counter.cpp @@ -0,0 +1,84 @@ +#include "../common.h" + +namespace esphome::counter::testing { + +TEST_F(CounterTest, StartsAtZero) { + this->counter_.setup(); + EXPECT_EQ(this->counter_.state, 0.0f); +} + +TEST(CounterInitialValue, PublishedAtSetupWhenNotRestoring) { + CounterSensor counter(false, -12); + counter.setup(); + EXPECT_EQ(counter.state, -12.0f); + counter.increment(); + EXPECT_EQ(counter.state, -11.0f); +} + +TEST_F(CounterTest, SetValuePublishesState) { + this->counter_.set_value(42); + EXPECT_EQ(this->counter_.state, 42.0f); +} + +TEST_F(CounterTest, IncrementDefaultsToOne) { + this->counter_.increment(); + this->counter_.increment(); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +TEST_F(CounterTest, IncrementAcceptsNegativeAmounts) { + this->counter_.set_value(10); + this->counter_.increment(-25); + EXPECT_EQ(this->counter_.state, -15.0f); +} + +TEST_F(CounterTest, ValueBeyondInt32) { + this->counter_.set_value(5000000000LL); + this->counter_.increment(5000000000LL); + EXPECT_EQ(this->counter_.state, 1.0e10f); +} + +TEST_F(CounterTest, IncrementWrapsAtInt64Limits) { + this->counter_.set_value(INT64_MAX_VALUE); + this->counter_.increment(1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MIN_VALUE)); + + this->counter_.set_value(INT64_MIN_VALUE); + this->counter_.increment(-1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MAX_VALUE)); +} + +TEST_F(CounterTest, CountsEachPublishFromSource) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + // The counted value is unrelated to what the source publishes. + source.publish_state(10.0f); + source.publish_state(10.0f); + source.publish_state(-3.5f); + EXPECT_EQ(this->counter_.state, 3.0f); +} + +TEST_F(CounterTest, SourceUpdatesAddToCurrentValue) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + this->counter_.set_value(100); + source.publish_state(1.0f); + EXPECT_EQ(this->counter_.state, 101.0f); +} + +TEST_F(CounterTest, CountsOnlyTrueFromBinarySource) { + FakeBinarySource source; + this->counter_.count_true_from(&source); + + source.publish(true); + source.publish(false); + EXPECT_EQ(this->counter_.state, 1.0f); + source.publish(true); + source.publish(false); + source.publish(false); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/test.esp32-idf.yaml b/tests/components/counter/test.esp32-idf.yaml new file mode 100644 index 00000000000..cc0d0d4e7f1 --- /dev/null +++ b/tests/components/counter/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + counter: !include common.yaml diff --git a/tests/components/counter/validate.esp32-idf.yaml b/tests/components/counter/validate.esp32-idf.yaml new file mode 100644 index 00000000000..22751677af6 --- /dev/null +++ b/tests/components/counter/validate.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + counter: !include common.yaml + +esphome: + on_shutdown: + then: + - counter.set_value: + id: counter_no_restore + value: -9223372036854775807 + - counter.increment: + id: counter_no_restore + value: 9223372036854775807 From ea4754e7984a8fc3672e4ecc0dc3d0af68d77f44 Mon Sep 17 00:00:00 2001 From: Pat Satyshur Date: Sun, 27 Sep 2026 04:48:08 -0500 Subject: [PATCH 592/759] [template][time] Add template time platform (#14838) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/template/time/__init__.py | 31 ++++++++++++ .../time/template_real_time_clock.cpp | 20 ++++++++ .../template/time/template_real_time_clock.h | 22 +++++++++ esphome/components/time/real_time_clock.h | 2 +- tests/components/template/common.yaml | 4 ++ tests/integration/fixtures/template_time.yaml | 47 ++++++++++++++++++ tests/integration/test_template_time.py | 49 +++++++++++++++++++ 7 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 esphome/components/template/time/__init__.py create mode 100644 esphome/components/template/time/template_real_time_clock.cpp create mode 100644 esphome/components/template/time/template_real_time_clock.h create mode 100644 tests/integration/fixtures/template_time.yaml create mode 100644 tests/integration/test_template_time.py diff --git a/esphome/components/template/time/__init__.py b/esphome/components/template/time/__init__.py new file mode 100644 index 00000000000..0332198e78d --- /dev/null +++ b/esphome/components/template/time/__init__.py @@ -0,0 +1,31 @@ +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_LAMBDA +from esphome.types import ConfigType + +from .. import template_ns + +TemplateRealTimeClock = template_ns.class_("TemplateRealTimeClock", time_.RealTimeClock) + + +CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateRealTimeClock), + cv.Required(CONF_LAMBDA): cv.returning_lambda, + cv.Optional(time_.CONF_ON_TIME_SYNC): cv.invalid( + "on_time_sync is not supported for template time, use on the source clock instead." + ), + } +).extend(cv.polling_component_schema("never")) + + +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) + + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.int64) + ) + cg.add(var.set_template(template_)) diff --git a/esphome/components/template/time/template_real_time_clock.cpp b/esphome/components/template/time/template_real_time_clock.cpp new file mode 100644 index 00000000000..4b523def251 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.cpp @@ -0,0 +1,20 @@ +#include "template_real_time_clock.h" + +namespace esphome::template_ { + +static const char *const TAG = "template.time"; + +time_t TemplateRealTimeClock::timestamp_now() { + auto val = this->f_.call(); + if (val.has_value()) { + auto value = *val; + if (value < 0 || value > std::numeric_limits::max()) { + ESP_LOGW(TAG, "timestamp value out of range: %lld", static_cast(value)); + return 0; + } + return static_cast(value); + } + return 0; +} + +} // namespace esphome::template_ diff --git a/esphome/components/template/time/template_real_time_clock.h b/esphome/components/template/time/template_real_time_clock.h new file mode 100644 index 00000000000..ec381115d63 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/components/time/real_time_clock.h" +#include "esphome/core/template_lambda.h" + +namespace esphome::template_ { + +class TemplateRealTimeClock : public time::RealTimeClock { + public: + template void set_template(F &&f) { this->f_.set(std::forward(f)); } + + /// The time is computed on demand and never written to the system clock, so there is nothing to poll. + void update() override {} + + /// Returns 0 (an invalid time) if the lambda gives no value. + time_t timestamp_now() override; + + protected: + TemplateLambda f_; +}; + +} // namespace esphome::template_ diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 90994d90f41..f98adb67b5b 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -29,7 +29,7 @@ class RealTimeClock : public PollingComponent { ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } /// Get the current time as the UTC epoch since January 1st 1970. - time_t timestamp_now() { return ::time(nullptr); } + virtual time_t timestamp_now() { return ::time(nullptr); } template void add_on_time_sync_callback(F &&callback) { this->time_sync_callback_.add(std::forward(callback)); diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index d06f3ce1317..a93fc0f5b6b 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -3,6 +3,10 @@ time: - platform: sntp # Required for datetime id: sntp_time + - platform: template + id: template_offset_time + lambda: |- + return id(sntp_time).timestamp_now() + 3600; wifi: # Required for sntp time ap: diff --git a/tests/integration/fixtures/template_time.yaml b/tests/integration/fixtures/template_time.yaml new file mode 100644 index 00000000000..d55e8fe6562 --- /dev/null +++ b/tests/integration/fixtures/template_time.yaml @@ -0,0 +1,47 @@ +esphome: + name: template-time-test +host: +api: +logger: + +time: + - platform: template + id: fixed_time + lambda: |- + return 1700000000; + + # Derived from another time source, as when offsetting a real clock. + - platform: template + id: offset_time + lambda: |- + return id(fixed_time).timestamp_now() + 3600; + + # A lambda that gives no value reports an invalid time. + - platform: template + id: empty_time + lambda: |- + return {}; + +text_sensor: + # Timestamps are exposed as text (rather than a 32-bit float sensor state, + # which cannot represent a UNIX epoch exactly) so the exact value can be checked. + - platform: template + name: "Fixed Timestamp" + id: fixed_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(fixed_time).now().timestamp); + + - platform: template + name: "Offset Timestamp" + id: offset_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(offset_time).now().timestamp); + + - platform: template + name: "Empty Time Valid" + id: empty_time_valid + update_interval: 100ms + lambda: |- + return std::string(id(empty_time).now().is_valid() ? "valid" : "invalid"); diff --git a/tests/integration/test_template_time.py b/tests/integration/test_template_time.py new file mode 100644 index 00000000000..c0a03fe2352 --- /dev/null +++ b/tests/integration/test_template_time.py @@ -0,0 +1,49 @@ +"""Integration test for the template time platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, TextSensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Timestamps are exposed as text sensors (rather than 32-bit float sensor states, +# which cannot represent a UNIX epoch exactly) so the exact value can be checked. +EXPECTED_STATES = { + "fixed_timestamp": "1700000000", + "offset_timestamp": "1700003600", + "empty_time_valid": "invalid", +} + + +@pytest.mark.asyncio +async def test_template_time( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Verify the template time platform evaluates its lambda on demand.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES)) + events = {name: asyncio.Event() for name in EXPECTED_STATES} + + def on_state(state: EntityState) -> None: + if isinstance(state, TextSensorState) and not state.missing_state: + name = key_to_name.get(state.key) + if name is not None and state.state == EXPECTED_STATES[name]: + events[name].set() + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, event in events.items(): + try: + await asyncio.wait_for(event.wait(), timeout=3.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {name} to report its expected value") From 2844236e77a496594d13d8267e101360688205d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 11:53:03 +0200 Subject: [PATCH 593/759] [light] Shrink LightState by 12 bytes per light (#19711) --- esphome/components/light/__init__.py | 34 +++++++--- esphome/components/light/effects.py | 6 ++ .../components/light/esp_color_correction.h | 6 ++ esphome/components/light/light_state.cpp | 40 ++++++++--- esphome/components/light/light_state.h | 48 ++++++++----- esphome/components/light/types.py | 1 + esphome/core/defines.h | 1 + tests/benchmarks/components/light/__init__.py | 12 ++-- .../components/light/bench_light_call.cpp | 7 +- .../light/test_light_state_layout.py | 37 ++++++++++ tests/components/light/__init__.py | 2 + tests/components/light/common.yaml | 1 + tests/components/light/test_light_state.cpp | 68 +++++++++++++++++++ 13 files changed, 215 insertions(+), 48 deletions(-) create mode 100644 tests/component_tests/light/test_light_state_layout.py create mode 100644 tests/components/light/test_light_state.cpp diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 328d01a167d..9ed183ea206 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -70,6 +70,7 @@ from .types import ( # noqa: F401 AddressableLightState, ChannelColors, ColorMode, + GammaTable, LightOutput, LightState, LightStateRTCState, @@ -85,6 +86,7 @@ CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True DOMAIN = "light" +CONF_GAMMA_TABLE_ID = "gamma_table_id" @dataclass @@ -140,18 +142,29 @@ def generate_gamma_table(gamma_correct: float) -> list[HexInt]: return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)] -def _get_or_create_gamma_table(gamma_correct): +def gamma_table_initializer(gamma_correct: float) -> str: + """C++ initializer for a light::GammaTable: the lookup table, then gamma * 100.""" + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(gamma_correct)) + # gamma_x100 is a uint16_t; platforms that redefine gamma_correct leave it unbounded, so saturate here + return f"{{{{{lut}}}, {min(0xFFFF, round(gamma_correct * 100))}}}" + + +def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpression: data = _get_data() if gamma_correct in data.gamma_tables: return data.gamma_tables[gamma_correct] - forward = generate_gamma_table(gamma_correct) - - gamma_str = f"{gamma_correct}".replace(".", "_") - fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16) - fwd_arr = cg.progmem_array(fwd_id, forward) - data.gamma_tables[gamma_correct] = fwd_arr - return fwd_arr + # table_id is generated and resolved against every declared ID, so it can't collide with a + # YAML ID; lights sharing a gamma reuse the first light's table. + cg.add( + cg.RawStatement( + f"static constexpr light::GammaTable {table_id} PROGMEM = " + f"{gamma_table_initializer(gamma_correct)};" + ) + ) + table = cg.RawExpression(f"&{table_id}") + data.gamma_tables[gamma_correct] = table + return table def find_effect_index(effects: list, effect_name: str) -> int | None: @@ -357,6 +370,7 @@ LIGHT_SCHEMA = ( .extend( { cv.GenerateID(): cv.declare_id(LightState), + cv.GenerateID(CONF_GAMMA_TABLE_ID): cv.declare_id(GammaTable), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), @@ -553,14 +567,14 @@ async def setup_light_core_(light_var, config, output_var): ) is not None and flash_transition_length != cv.time_period( DEFAULT_FLASH_TRANSITION_LENGTH ): + cg.add_define("USE_LIGHT_FLASH_TRANSITION_LENGTH") cg.add(light_var.set_flash_transition_length(flash_transition_length)) # Setting an interval opts this light in and compiles the feature in if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None: cg.add(light_var.set_transition_state_publish_interval(interval)) cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL") if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: - cg.add(light_var.set_gamma_correct(gamma_correct)) - fwd_arr = _get_or_create_gamma_table(gamma_correct) + fwd_arr = _get_or_create_gamma_table(gamma_correct, config[CONF_GAMMA_TABLE_ID]) cg.add(light_var.set_gamma_table(fwd_arr)) cg.add_define("USE_LIGHT_GAMMA_LUT") effects = await cg.build_registry_list( diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 535def0ba02..b12ec189b9e 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -545,12 +545,18 @@ async def addressable_flicker_effect_to_code(config, effect_id): return var +# LightState stores the active effect index in a uint16_t +MAX_EFFECTS = 65535 + + def validate_effects(allowed_effects): @schema_extractor("effects") def validator(value): if value == SCHEMA_EXTRACT: return (allowed_effects, EFFECTS_REGISTRY) + if isinstance(value, list) and len(value) > MAX_EFFECTS: + raise cv.Invalid(f"A light supports at most {MAX_EFFECTS} effects") value = cv.validate_registry("effect", EFFECTS_REGISTRY)(value) errors = [] names = set() diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 4eb5208c96e..0295fb36b8c 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -5,6 +5,12 @@ namespace esphome::light { +/// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from +struct GammaTable { + uint16_t lut[256]; + uint16_t gamma_x100; +}; + /// Binary search a monotonically increasing uint16[256] PROGMEM table. /// Returns the largest index where table[index] <= target. inline uint8_t gamma_table_reverse_search(const uint16_t *table, uint16_t target) { diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9cbe9639dbf..1b9a7f94c1b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -9,6 +9,8 @@ #include "light_output.h" #include "transformers.h" +#include + namespace esphome::light { static const char *const TAG = "light"; @@ -94,10 +96,17 @@ void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); auto traits = this->get_traits(); if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { +#ifdef USE_LIGHT_GAMMA_LUT + // Read the stored gamma * 100 directly so dump_config does not pull in get_gamma_correct() + const unsigned gamma_x100 = + this->gamma_table_ != nullptr ? progmem_read_uint16(&this->gamma_table_->gamma_x100) : 0; +#else + const unsigned gamma_x100 = 0; +#endif ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" - " Gamma Correct: %.2f", - this->default_transition_length_ / 1e3f, this->gamma_correct_); + " Gamma Correct: %u.%02u", + this->default_transition_length_ / 1e3f, gamma_x100 / 100, gamma_x100 % 100); #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL // The define is build wide; only lights that set the option have an interval if (this->transition_state_publish_interval_ != 0) { @@ -296,6 +305,14 @@ void LightState::current_values_as_ct(float *color_temperature, float *white_bri *white_brightness = this->gamma_correct_lut(*white_brightness); } +float LightState::get_gamma_correct() const { +#ifdef USE_LIGHT_GAMMA_LUT + if (this->gamma_table_ != nullptr) + return progmem_read_uint16(&this->gamma_table_->gamma_x100) * 0.01f; +#endif // USE_LIGHT_GAMMA_LUT + return 0.0f; +} + #ifdef USE_LIGHT_GAMMA_LUT float LightState::gamma_correct_lut(float value) const { if (value <= 0.0f) @@ -307,10 +324,10 @@ float LightState::gamma_correct_lut(float value) const { float scaled = value * 255.0f; auto idx = static_cast(scaled); if (idx >= 255) - return progmem_read_uint16(&this->gamma_table_[255]) / 65535.0f; + return progmem_read_uint16(&this->gamma_table_->lut[255]) / 65535.0f; float frac = scaled - idx; - float a = progmem_read_uint16(&this->gamma_table_[idx]); - float b = progmem_read_uint16(&this->gamma_table_[idx + 1]); + float a = progmem_read_uint16(&this->gamma_table_->lut[idx]); + float b = progmem_read_uint16(&this->gamma_table_->lut[idx + 1]); return (a + frac * (b - a)) / 65535.0f; } float LightState::gamma_uncorrect_lut(float value) const { @@ -321,12 +338,12 @@ float LightState::gamma_uncorrect_lut(float value) const { if (this->gamma_table_ == nullptr) return value; uint16_t target = static_cast(value * 65535.0f); - uint8_t lo = gamma_table_reverse_search(this->gamma_table_, target); + uint8_t lo = gamma_table_reverse_search(this->gamma_table_->lut, target); if (lo >= 255) return 1.0f; // Interpolate between lo and lo+1 - uint16_t a = progmem_read_uint16(&this->gamma_table_[lo]); - uint16_t b = progmem_read_uint16(&this->gamma_table_[lo + 1]); + uint16_t a = progmem_read_uint16(&this->gamma_table_->lut[lo]); + uint16_t b = progmem_read_uint16(&this->gamma_table_->lut[lo + 1]); if (b == a) return lo / 255.0f; float frac = static_cast(target - a) / static_cast(b - a); @@ -335,11 +352,14 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { + // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold + if (effect_index > std::numeric_limits::max()) + return; this->stop_effect_(); if (effect_index == 0) return; - this->active_effect_index_ = effect_index; + this->active_effect_index_ = static_cast(effect_index); auto *effect = this->get_active_effect_(); effect->start_internal(); // Enable loop while effect is active @@ -435,7 +455,7 @@ void LightState::save_remote_values_() { saved.color_temp = this->remote_values.get_color_temperature(); saved.cold_white = this->remote_values.get_cold_white(); saved.warm_white = this->remote_values.get_warm_white(); - saved.effect = this->active_effect_index_; + saved.effect = static_cast(this->active_effect_index_); // the saved layout stays uint32_t this->rtc_.save(&saved); } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 4f7d3c93302..f20fecc8dda 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/string_ref.h" #include "light_call.h" +#include "esp_color_correction.h" #include "light_color_values.h" #include "light_effect.h" #include "light_traits.h" @@ -151,15 +152,28 @@ class LightState : public EntityBase, public Component { } uint32_t get_default_transition_length() const { return this->default_transition_length_; } - /// Set the flash transition length +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH + /// Set the flash transition length; only compiled in when a light configures one 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_; } +#else + // Remove before 2027.4.0 + ESPDEPRECATED("set_flash_transition_length() does nothing unless flash_transition_length is set in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_flash_transition_length(uint32_t flash_transition_length) {} + uint32_t get_flash_transition_length() const { return 0; } +#endif - /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } - float get_gamma_correct() const { return this->gamma_correct_; } + // Remove before 2027.4.0 + ESPDEPRECATED("set_gamma_correct() does nothing; gamma is fixed at build time by gamma_correct in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_gamma_correct(float gamma_correct) {} + /// The gamma correction factor, read from the entry after the gamma lookup table; 0 without one + float get_gamma_correct() const; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) { @@ -169,11 +183,11 @@ class LightState : public EntityBase, public Component { #endif #ifdef USE_LIGHT_GAMMA_LUT - /// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array) - void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; } + /// Set the pre-computed PROGMEM gamma curve + void set_gamma_table(const GammaTable *table) { this->gamma_table_ = table; } - /// Get the forward gamma lookup table - const uint16_t *get_gamma_table() const { return this->gamma_table_; } + /// Get the forward gamma lookup table, 256 PROGMEM entries + const uint16_t *get_gamma_table() const { return this->gamma_table_ != nullptr ? this->gamma_table_->lut : nullptr; } /// Apply gamma correction using the pre-computed forward LUT float gamma_correct_lut(float value) const; @@ -362,31 +376,31 @@ class LightState : public EntityBase, public Component { /// Values live in flash as function body; no per-instance data storage beyond this pointer. void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH /// Transition length to use for flash transitions. uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py +#endif #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL uint32_t transition_state_publish_interval_{0}; uint32_t last_transition_state_publish_{0}; #endif - /// Gamma correction factor for the light. - float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT - const uint16_t *gamma_table_{nullptr}; + const GammaTable *gamma_table_{nullptr}; #endif // USE_LIGHT_GAMMA_LUT + /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py + uint16_t active_effect_index_{}; /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. - bool is_transformer_active_{false}; + bool is_transformer_active_ : 1 {false}; /// Whether this light persists its state to preferences at all. - bool save_enabled_{false}; + bool save_enabled_ : 1 {false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL /// True while the active transformer publishes current_values on an interval from loop(). - bool transition_publish_enabled_{false}; + bool transition_publish_enabled_ : 1 {false}; #endif }; diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index ceb974f26d7..7a95920a740 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +GammaTable = light_ns.struct("GammaTable") LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 29b686e03c2..757e3cfe79d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_JSON #define USE_RADIO_FREQUENCY #define USE_LIGHT +#define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py index 233a3c246e5..bffaf0febf1 100644 --- a/tests/benchmarks/components/light/__init__.py +++ b/tests/benchmarks/components/light/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.light import generate_gamma_table +from esphome.components.light import gamma_table_initializer from tests.testing_helpers import ComponentManifestOverride @@ -12,15 +12,13 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) cg.add_define("USE_LIGHT_GAMMA_LUT") - # Use the light component's own generate_gamma_table() so the + # Use the light component's own gamma_table_initializer() so the # benchmark stays in sync with any formula changes. - forward = generate_gamma_table(2.8) - values = ", ".join(f"0x{int(v):04X}" for v in forward) - # Use extern-visible (non-static) array so the benchmark .cpp - # can reference it via extern declaration. + # Extern-visible (non-static) so the benchmark .cpp can reference it. cg.add_global( cg.RawStatement( - f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + "extern const esphome::light::GammaTable bench_gamma_2_8 PROGMEM = " + f"{gamma_table_initializer(2.8)};" ) ) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index cc6871654de..e102d323714 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -4,8 +4,8 @@ #include "esphome/components/light/light_state.h" // Gamma 2.8 forward LUT generated by the light component's Python codegen -// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) -extern const uint16_t bench_gamma_2_8_fwd[256]; +// (see tests/benchmarks/components/light/__init__.py which calls gamma_table_initializer()) +extern const esphome::light::GammaTable bench_gamma_2_8; namespace esphome::benchmarks { @@ -37,8 +37,7 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { output.traits_.set_max_mireds(500.0f); light.configure("test_light"); light.set_default_transition_length(0); - light.set_gamma_correct(2.8f); - light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_gamma_table(&bench_gamma_2_8); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/component_tests/light/test_light_state_layout.py b/tests/component_tests/light/test_light_state_layout.py new file mode 100644 index 00000000000..2d6c6e1523e --- /dev/null +++ b/tests/component_tests/light/test_light_state_layout.py @@ -0,0 +1,37 @@ +"""Limits and flash layout that let LightState stay small.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.effects import ( + MAX_EFFECTS, + MONOCHROMATIC_EFFECTS, + validate_effects, +) + + +def _effects(count: int) -> list[dict[str, dict[str, str]]]: + return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)] + + +def test_rejects_more_effects_than_the_index_holds() -> None: + with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"): + validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1)) + + +def test_accepts_a_normal_effect_list() -> None: + assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3 + + +def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None: + from esphome.components.light import gamma_table_initializer, generate_gamma_table + + init = gamma_table_initializer(2.8) + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8)) + assert init == f"{{{{{lut}}}, 280}}" + + +def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None: + from esphome.components.light import gamma_table_initializer + + assert gamma_table_initializer(1000.0).endswith(", 65535}") diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index d5cdb70c9c8..2d5eb9223dc 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER") # api_connection.cpp reports the port whenever USE_WEBSERVER is set cg.add_define("USE_WEBSERVER_PORT", 80) + # The gamma table and its stored gamma are behind this define + cg.add_define("USE_LIGHT_GAMMA_LUT") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 9e3242f9a19..15829b14638 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -254,6 +254,7 @@ light: state: INITIAL brightness: INITIAL - platform: monochromatic + flash_transition_length: 100ms id: test_monochromatic_light name: Monochromatic Light output: test_ledc_1 diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp new file mode 100644 index 00000000000..85bfbd09ef5 --- /dev/null +++ b/tests/components/light/test_light_state.cpp @@ -0,0 +1,68 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t. +class TestableLightState : public LightState { + public: + using LightState::LightState; + using LightState::start_effect_; +}; + +} // namespace + +// add_effects() is public, so an external component can exceed the codegen cap on effect count; +// an index the uint16_t can't hold must be ignored rather than wrap onto another effect. +TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) { + BrightnessOutput output; + TestableLightState state(&output); + NoopEffect effect("Noop"); + state.add_effects({&effect}); + + state.start_effect_(1); + ASSERT_EQ(state.get_current_effect_index(), 1u); + + state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect + EXPECT_EQ(state.get_current_effect_index(), 1u); +} + +// get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals. +TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) { + static constexpr GammaTable TABLE{{}, 280}; + BrightnessOutput output; + LightState state(&output); + state.set_gamma_table(&TABLE); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 2.8f); + EXPECT_EQ(state.get_gamma_table(), TABLE.lut); +} + +TEST(LightStateGamma, IsZeroWithoutATable) { + BrightnessOutput output; + LightState state(&output); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 0.0f); + EXPECT_EQ(state.get_gamma_table(), nullptr); +} + +} // namespace esphome::light::testing From a6d525a44e5209c8b93bc18dd3d9d6e90e8ddd2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 12:07:55 +0200 Subject: [PATCH 594/759] [core] Make the generated apply function a template argument of ApplyAction and ApplyCondition (#19655) --- esphome/automation.py | 39 ++++++---- esphome/codegen.py | 1 + esphome/core/base_automation.h | 25 ++----- esphome/cpp_generator.py | 24 ++++++- .../components/http_request/http_request.yaml | 4 +- tests/unit_tests/test_automation.py | 72 ++++++++++++------- 6 files changed, 106 insertions(+), 59 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 2f970b915be..c8d0e4c5743 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -345,14 +345,28 @@ async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: - # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function - # pointer conversion. + # The generated function's parameters; a std::string arg is never copied. return [ (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) for t, arg in args ] +def _apply_function( + id_: ID, + return_type: SafeExpType, + template_arg: cg.TemplateArguments, + lambda_args: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or + ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" + fn = cg.static_function( + f"esphome__{id_.id}__fn", return_type, lambda_args, statements + ) + return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + async def _render_values( name: str, target: str, @@ -403,8 +417,8 @@ def register_apply_action( ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. - Generates one stateless function for ``ApplyAction``: the parent (read from - ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + Generates one static function with the parent (read from ``id_key``) and constants baked + in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it. A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. @@ -449,10 +463,9 @@ def register_apply_action( *statements, "apply_call.perform();", ] - apply_lambda = LambdaExpression( - ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + return _apply_function( + action_id, cg.void, template_arg, lambda_args, statements ) - return cg.new_Pvariable(action_id, template_arg, apply_lambda) register_action(name, ApplyAction, schema, synchronous=True)(builder) @@ -466,7 +479,7 @@ def register_apply_condition( ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. - Generates one stateless function for ``ApplyCondition``. + Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) members = call.members @@ -492,13 +505,13 @@ def register_apply_condition( lambda_args, compare=True, ) - check_lambda = LambdaExpression( - [f"return {parent}->{call.target.format(*exprs)};"], + return _apply_function( + condition_id, + cg.bool_, + template_arg, lambda_args, - capture="", - return_type=cg.bool_, + [f"return {parent}->{call.target.format(*exprs)};"], ) - return cg.new_Pvariable(condition_id, template_arg, check_lambda) register_condition(name, ApplyCondition, schema)(builder) diff --git a/esphome/codegen.py b/esphome/codegen.py index 3dfec65f233..4de1d8d5c19 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -42,6 +42,7 @@ from esphome.cpp_generator import ( # noqa: F401 set_cpp_standard, statement, static_const_array, + static_function, templatable, variable, with_local_variable, diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 703c7b40e33..8762cbe7110 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -253,30 +253,19 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; -/// Runs one codegen-generated function that has the parent and every field baked in, so the -/// action holds one pointer. Args pass by const reference so a std::string arg is never copied; +/// Runs one codegen-generated function that has the parent and every field baked in. The +/// function is a template argument, so play() calls it directly and the object is just the +/// Action base. Args pass by const reference so a std::string arg is never copied; /// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. -template class ApplyAction final : public Action { +template class ApplyAction final : public Action { public: - using ApplyFn = void (*)(const std::remove_cvref_t &...); - explicit ApplyAction(ApplyFn apply) : apply_(apply) {} - - void play(const Ts &...x) override { this->apply_(x...); } - - protected: - ApplyFn apply_; + void play(const Ts &...x) override { Fn(x...); } }; /// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. -template class ApplyCondition final : public Condition { +template class ApplyCondition final : public Condition { public: - using CheckFn = bool (*)(const std::remove_cvref_t &...); - explicit ApplyCondition(CheckFn check) : check_(check) {} - - bool check(const Ts &...x) override { return this->check_(x...); } - - protected: - CheckFn check_; + bool check(const Ts &...x) override { return Fn(x...); } }; /// Simple continuation action that calls play_next_ on a parent action. diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a681583053e..b0c3533e040 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last +from esphome.helpers import cpp_string_escape, indent, indent_all_but_first_and_last from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase @@ -670,6 +670,28 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": return Pvariable(id_, rhs) +def static_function( + name: str, + return_type: SafeExpType, + parameters: TemplateArgsType, + body: list[str], +) -> RawExpression: + """Emit ``static (parameters) { body }`` at global scope and return an + expression naming it, for use as a template argument or a function pointer. + + Every id the body names must already be declared, which holds when the statements were + rendered through ``get_variable`` or ``process_lambda``. + """ + params = ParameterListExpression(*parameters) + add_global( + RawStatement( + f"static {safe_exp(return_type)} {name}({params}) {{\n" + f"{indent(chr(10).join(body))}\n}}" + ) + ) + return RawExpression(name) + + def add(expression: Expression | Statement, prepend: bool = False): """Add an expression to the codegen section. diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 172e0c235ad..eb8a6d33e7a 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -53,8 +53,8 @@ esphome: # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen - # bug where the apply lambda's parameter list did not match the - # ApplyFn signature. + # bug where the generated function's parameter list did not match + # the automation's arguments. - light.turn_on: id: test_regression_light brightness: 100% diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 001756d72fc..3a902a429f6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -509,6 +509,8 @@ class MockCodegen(NamedTuple): get_variable: AsyncMock new_pvariable: MagicMock register_parented: AsyncMock + add_global: MagicMock + calls: MagicMock # new_pvariable and add_global attached, to check their order @pytest.fixture @@ -520,10 +522,16 @@ def mock_cg() -> Generator[MockCodegen]: patch( "esphome.codegen.register_parented", new_callable=AsyncMock ) as register_parented, + patch("esphome.cpp_generator.add_global") as add_global, ): get_variable.return_value = PARENT_OBJ new_pvariable.return_value = NEW_OBJ - yield MockCodegen(get_variable, new_pvariable, register_parented) + calls = MagicMock() + calls.attach_mock(new_pvariable, "new_pvariable") + calls.attach_mock(add_global, "add_global") + yield MockCodegen( + get_variable, new_pvariable, register_parented, add_global, calls + ) @pytest.fixture @@ -649,8 +657,8 @@ async def _run_apply_condition( return await _run_entry(conditions["my.check"], config, args, platform, id_key) -def _apply_lambda(mock_cg: MockCodegen) -> str: - return str(mock_cg.new_pvariable.call_args.args[2]) +def _apply_definition(mock_cg: MockCodegen) -> str: + return str(mock_cg.add_global.call_args.args[0]) @pytest.mark.asyncio @@ -661,9 +669,14 @@ async def test_register_apply_action_entry( assert entry.type_id is ApplyAction assert entry.synchronous is True mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) - action_id, template_arg, _ = mock_cg.new_pvariable.call_args.args + action_id, template_arg = mock_cg.new_pvariable.call_args.args assert action_id == ID("obj_1") - assert str(template_arg) == "" + assert str(template_arg) == "" + # The definition must precede the storage line that names the function. + assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"] + assert _apply_definition(mock_cg).startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) @pytest.mark.asyncio @@ -692,7 +705,7 @@ async def test_apply_constants( ) config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5} await _run_apply_action(registries, fields, config) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) lines = [ f"::{PARENT_OBJ}->set_kp(0.0f);", f"::{PARENT_OBJ}->set_on(false);", @@ -715,7 +728,9 @@ async def test_apply_id_constant_is_the_named_object( fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),) await _run_apply_action(registries, fields, {"target": ID("speaker_b")}) mock_cg.get_variable.assert_any_await(ID("speaker_b")) - assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) @pytest.mark.asyncio @@ -728,7 +743,9 @@ async def test_apply_condition_id_constant_is_the_named_object( check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),)) await _run_apply_condition(registries, check, {"target": ID("speaker_b")}) mock_cg.get_variable.assert_any_await(ID("speaker_b")) - assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in _apply_lambda(mock_cg) + assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) @pytest.mark.asyncio @@ -745,8 +762,10 @@ async def test_apply_lambdas( "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), } await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) - text = _apply_lambda(mock_cg) - assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + text = _apply_definition(mock_cg) + assert text.startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) # The parent is global-scope qualified, so an arg named like the id cannot shadow it. assert f"::{PARENT_OBJ}->set_kp(" in text assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text @@ -768,11 +787,11 @@ async def test_apply_call_keys( ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))), ) await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0}) - assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_definition(mock_cg) mock_cg.new_pvariable.reset_mock() await _run_apply_action(registries, fields, {}) - assert "set_range" not in _apply_lambda(mock_cg) + assert "set_range" not in _apply_definition(mock_cg) with pytest.raises(EsphomeError, match="needs all of"): await _run_apply_action(registries, fields, {"low": 1.0}) @@ -784,7 +803,7 @@ async def test_apply_action_call_shape( ) -> None: fields = (ApplyField("brightness", "set_brightness", cg.float_),) await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call") - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) lines = [ f"auto apply_call = ::{PARENT_OBJ}->make_call();", "apply_call.set_brightness(0.5f);", @@ -814,7 +833,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string( "value": Lambda("return 42;"), } await _run_apply_action(registries, fields, config) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert f"::{PARENT_OBJ}->set_direction(3);" in text assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text assert ( @@ -824,7 +843,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string( mock_cg.new_pvariable.reset_mock() await _run_apply_action(registries, fields[:1], {}) - assert "set_direction" not in _apply_lambda(mock_cg) + assert "set_direction" not in _apply_definition(mock_cg) def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: @@ -883,8 +902,9 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( ) -> None: fields = (ApplyField("song", "play", cg.std_string),) await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266") - assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( - mock_cg + assert ( + f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' + in _apply_definition(mock_cg) ) @@ -900,7 +920,7 @@ async def test_apply_literal_with_length_is_plain_on_every_platform( await _run_apply_action( registries, fields, {"option": "h\u00e9llo"}, platform=platform ) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text assert "progmem_string" not in text @@ -913,11 +933,13 @@ async def test_register_apply_condition_predicate( registries, "is_playing()", {}, args=[(cg.int32, "x")] ) assert entry.type_id is ApplyCondition - condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args + condition_id, template_arg = mock_cg.new_pvariable.call_args.args assert condition_id == ID("obj_1") - assert str(template_arg) == "" - text = str(check) - assert text.startswith("[](const std::remove_cvref_t & x) -> bool {") + assert str(template_arg) == "" + text = _apply_definition(mock_cg) + assert text.startswith( + "static bool esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) assert f"return ::{PARENT_OBJ}->is_playing();" in text @@ -927,7 +949,7 @@ async def test_apply_condition_compares_config_value( ) -> None: check = ApplyCall("state == {}", (("state", cg.bool_),)) await _run_apply_condition(registries, check, {"state": True}) - assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_definition(mock_cg) with pytest.raises(EsphomeError, match="needs all of"): await _run_apply_condition(registries, check, {}) @@ -940,7 +962,7 @@ async def test_apply_condition_string_constant_is_a_plain_literal( ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) @pytest.mark.asyncio @@ -964,6 +986,6 @@ async def test_apply_condition_string_lambda_paths( await _run_apply_condition( registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] ) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert expected in text assert ("-> std::string {" in text) is called From 06d88e0381c6f26218b65086d080847a3a3bb70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 12:08:36 +0200 Subject: [PATCH 595/759] [core][sensor][binary_sensor][text_sensor][debug][xiaomi_rtcgq02lm] Add new_sub_sensor, new_sub_binary_sensor and new_sub_text_sensor helpers (#19725) --- AGENTS.md | 10 +++++ esphome/components/binary_sensor/__init__.py | 15 ++++++- esphome/components/debug/sensor.py | 42 +++++++------------ esphome/components/debug/text_sensor.py | 12 +++--- esphome/components/sensor/__init__.py | 13 ++++++ esphome/components/text_sensor/__init__.py | 16 ++++++- .../xiaomi_rtcgq02lm/binary_sensor.py | 18 ++++---- esphome/components/xiaomi_rtcgq02lm/sensor.py | 4 +- esphome/core/entity_helpers.py | 24 ++++++++++- tests/unit_tests/core/test_entity_helpers.py | 39 +++++++++++++++++ 10 files changed, 142 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30ce6dfe9a8..670cf55b039 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,16 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Optional child entities of a hub:** use `new_sub_sensor`, `new_sub_binary_sensor` or + `new_sub_text_sensor`. Each creates the entity only when its key is configured and passes it to + the setter. Always name the setter explicitly; never build it with `getattr` and an f-string. + ```python + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await sensor.new_sub_sensor(config, CONF_TEMPERATURE, var.set_temperature_sensor) + await sensor.new_sub_sensor(config, CONF_HUMIDITY, var.set_humidity_sensor) + ``` + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 6b7c3dcb163..fb2ade82ce1 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from logging import getLogger from esphome import automation, core @@ -64,11 +65,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -632,6 +635,16 @@ async def new_binary_sensor(config, *args): return var +async def new_sub_binary_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the binary sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_binary_sensor, config, key, setter, *args) + + BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BinarySensor), diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 80d1daa81f1..35eba48d13e 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -108,30 +108,18 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + await sensor.new_sub_sensor(config, CONF_FREE, debug_component.set_free_sensor) + await sensor.new_sub_sensor(config, CONF_BLOCK, debug_component.set_block_sensor) + await sensor.new_sub_sensor( + config, CONF_FRAGMENTATION, debug_component.set_fragmentation_sensor + ) + await sensor.new_sub_sensor( + config, CONF_MIN_FREE, debug_component.set_min_free_sensor + ) + await sensor.new_sub_sensor( + config, CONF_LOOP_TIME, debug_component.set_loop_time_sensor + ) + await sensor.new_sub_sensor(config, CONF_PSRAM, debug_component.set_psram_sensor) + await sensor.new_sub_sensor( + config, CONF_CPU_FREQUENCY, debug_component.set_cpu_frequency_sensor + ) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 2e02af67cbe..cac7da0f232 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -33,9 +33,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + await text_sensor.new_sub_text_sensor( + config, CONF_DEVICE, debug_component.set_device_info_sensor + ) + await text_sensor.new_sub_text_sensor( + config, CONF_RESET_REASON, debug_component.set_reset_reason_sensor + ) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 62d78a32dd7..5df4a9acbdc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import logging import math @@ -115,6 +116,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, @@ -122,6 +124,7 @@ from esphome.core.entity_helpers import ( ) from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -1013,6 +1016,16 @@ async def new_sensor(config, *args): return var +async def new_sub_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_sensor, config, key, setter, *args) + + SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Sensor), diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 34c46d5451b..392fe325d57 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -23,11 +25,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, Expression, SafeExpType from esphome.util import Registry DEVICE_CLASSES = [ @@ -231,6 +235,16 @@ async def new_text_sensor(config, *args): return var +async def new_sub_text_sensor( + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, +) -> MockObj | None: + """Create the text sensor configured under key, if any, and pass it to setter.""" + return await new_sub_entity(new_text_sensor, config, key, setter, *args) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 57420125cb6..f1b01cfd61f 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -49,16 +49,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) - if CONF_MOTION in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_MOTION]) - cg.add(parent.set_motion(sens)) + if await binary_sensor.new_sub_binary_sensor( + config, CONF_MOTION, parent.set_motion + ): cg.add(parent.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) - - if CONF_LIGHT in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_LIGHT]) - cg.add(parent.set_light(sens)) - - if CONF_BUTTON in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_BUTTON]) - cg.add(parent.set_button(sens)) + await binary_sensor.new_sub_binary_sensor(config, CONF_LIGHT, parent.set_light) + if await binary_sensor.new_sub_binary_sensor( + config, CONF_BUTTON, parent.set_button + ): cg.add(parent.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e0e4b4640bd..587b1b594ea 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -33,6 +33,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(parent.set_battery_level(sens)) + await sensor.new_sub_sensor(config, CONF_BATTERY_LEVEL, parent.set_battery_level) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 54e2551cb4e..2956ac60016 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,7 +1,8 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -32,7 +33,7 @@ from esphome.helpers import ( sanitize, snake_case, ) -from esphome.types import ConfigType, EntityMetadata +from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -467,6 +468,25 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +async def new_sub_entity( + new_entity: Callable[..., Awaitable[MockObj]], + config: ConfigType, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, +) -> MockObj | None: + """Create the entity configured under key, if any, and pass it to setter. + + Returns None only when key is not configured, so the result can be used directly as a condition. + """ + if (conf := config.get(key)) is None: + return None + var = await new_entity(conf, *args, **kwargs) + add(setter(var)) + return var + + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. If a property is already set, it will not be inherited. diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 53035ad7136..b10ba1de27c 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1236,3 +1236,42 @@ async def test_finalize_comment_sanitization( # Newline must be replaced to prevent breaking out of comment assert "\n" not in comment_line assert "INJECTED_CODE" in comment_line # still visible but safe in comment + + +@pytest.mark.asyncio +async def test_new_sub_entity_creates_and_sets( + setup_test_environment: list[str], +) -> None: + """A configured key, even with an empty config, creates the entity and passes it to the setter.""" + sub_config: dict[str, Any] = {} + created = MockObj("child") + calls: list[tuple[Any, ...]] = [] + + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf, args, kwargs)) + return created + + parent = MockObj("parent", "->") + result = await entity_helpers.new_sub_entity( + new_entity, {"child": sub_config}, "child", parent.set_child, 1, step=2 + ) + + assert result is created + assert calls == [(sub_config, (1,), {"step": 2})] + assert setup_test_environment == ["parent->set_child(child)"] + + +@pytest.mark.asyncio +async def test_new_sub_entity_missing_key(setup_test_environment: list[str]) -> None: + """A missing key creates nothing and emits no code.""" + + async def new_entity(conf: dict[str, Any], *args: Any) -> MockObj: + raise AssertionError("must not be called") + + parent = MockObj("parent", "->") + result = await entity_helpers.new_sub_entity( + new_entity, {}, "child", parent.set_child + ) + + assert result is None + assert setup_test_environment == [] From b9dfae50c1b2bf7108833524713e119d9a9942f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 12:09:06 +0200 Subject: [PATCH 596/759] [ci] Compile esp8266 component tests with the native toolchain (#19740) --- .github/workflows/ci.yml | 29 ++++--- script/determine-jobs.py | 27 ++++-- tests/script/test_determine_jobs.py | 87 ++++++++++++++++++- .../build_components_base.esp8266-ard.yaml | 1 + 4 files changed, 120 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11d073e7e32..6c89712e054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,9 +193,10 @@ jobs: needs: - common # PR-branch cache saves are invisible to other PRs, so dev pushes seed - # the shared entry test-esp8266-native restores. Only dev: the composite - # action saves nowhere else, so a beta/release push would download the - # toolchain and discard it. + # the shared entry the component matrix, the memory impact jobs and + # test-esp8266-native restore. Only dev: the composite action saves + # nowhere else, so a beta/release push would download the toolchain and + # discard it. if: github.event_name == 'push' && github.ref == 'refs/heads/dev' timeout-minutes: 15 steps: @@ -1127,6 +1128,11 @@ jobs: uses: ./.github/actions/cache-sdk-nrf with: restore-only: true + - name: Cache the native ESP8266 toolchain + # Only batches whose test platforms include esp8266; never saves + # here, it reuses the install the dev seed job cached. + if: matrix.batch.needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1479,12 +1485,9 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' @@ -1569,11 +1572,9 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory id: extract run: | diff --git a/script/determine-jobs.py b/script/determine-jobs.py index aa930745814..a45951a52a2 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -724,11 +724,17 @@ def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool: def esp8266_native_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to - compile (same narrowing as ``esp32_platformio_components_to_test``).""" - return _toolchain_components_to_test( - branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger - ) + """The smoke set on a native-build change, nothing otherwise. + + Unlike the esp32 PlatformIO job, this one does not narrow to the changed + components: the component matrix already compiles every esp8266 fixture + with this toolchain, so the only gap left is a change to the native build + itself that brings no component along. + """ + files = changed_files(branch) + if core_changed(files) or _esp8266_native_path_or_file_trigger(files): + return sorted(ESP8266_NATIVE_TEST_COMPONENTS) + return [] def determine_cpp_unit_tests( @@ -1286,6 +1292,7 @@ def detect_memory_impact_config( "components": compatible_components, "platform": platform, "use_merged_config": "true", + "needs_arduino8266": platform.startswith("esp8266"), } @@ -1516,12 +1523,20 @@ def main() -> None: for batch in batches: platforms: set[str] = set() for component in batch: - platforms.update(get_component_test_platforms(component)) + # Variants included: the compile stage builds them, so a + # component tested only by test-..yaml + # still needs that platform's toolchain + platforms.update( + get_component_test_platforms(component, base_only=False) + ) component_test_batches.append( { "components": " ".join(batch), "needs_idf": any(p.startswith("esp32") for p in platforms), "needs_nrf": any(p.startswith("nrf52") for p in platforms), + "needs_arduino8266": any( + p.startswith("esp8266") for p in platforms + ), } ) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 4903f1a88ad..c3f30b8ed17 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -250,6 +250,82 @@ def test_main_all_tests_should_run( assert len(batch["components"]) > 0 assert isinstance(batch["needs_idf"], bool) assert isinstance(batch["needs_nrf"], bool) + assert isinstance(batch["needs_arduino8266"], bool) + + +def test_main_batch_flags_count_variant_tests( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The compile stage builds test-..yaml too, so a + component tested on esp8266 only by a variant still needs the toolchain.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"] + + def platforms(component: str, *, base_only: bool = True) -> set[str]: + return set() if base_only else {"esp8266-ard"} + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, "get_changed_components", return_value=["safe_mode"] + ), + patch.object( + determine_jobs, + "filter_component_and_test_files", + side_effect=lambda f: f.startswith("esphome/components/"), + ), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=["safe_mode"], + ), + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["safe_mode"]], {}), + ), + patch.object( + determine_jobs, "get_component_test_platforms", side_effect=platforms + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + assert output["component_test_batches"] == [ + { + "components": "safe_mode", + "needs_idf": False, + "needs_nrf": False, + "needs_arduino8266": True, + } + ] def test_main_no_tests_should_run( @@ -1530,6 +1606,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non assert set(result["components"]) == {"wifi", "api"} assert result["platform"] == "esp32-idf" # Common platform assert result["use_merged_config"] == "true" + assert result["needs_arduino8266"] is False @pytest.mark.usefixtures("mock_target_branch_dev") @@ -1634,6 +1711,8 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["platform"] == "esp8266-ard" assert result["components"] == ["logger"] assert result["use_merged_config"] == "true" + # The esp8266 build is native, so the job restores that toolchain + assert result["needs_arduino8266"] is True @pytest.mark.usefixtures("mock_target_branch_dev") @@ -3212,13 +3291,13 @@ def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> No @pytest.mark.parametrize( ("changed_files", "dependency_closure", "expected"), [ - # Tested component changed -- narrow to the intersection. + # A tested component alone does not schedule this job: the component + # matrix already compiles its esp8266 fixtures with this toolchain. ( ["esphome/components/mqtt/mqtt_client.cpp"], ["mqtt", "json"], - ["mqtt"], + [], ), - # Components outside the test set return an empty list (job skipped). ( ["esphome/components/wifi/wifi_component.cpp"], ["wifi", "network"], @@ -3236,7 +3315,7 @@ def test_esp8266_native_components_to_test_narrowing( dependency_closure: list[str], expected: list[str], ) -> None: - """Component changes narrow the native-ESP8266 test list.""" + """Only a native-build change schedules the native-ESP8266 job.""" with ( patch.object(determine_jobs, "changed_files", return_value=changed_files), patch.object( diff --git a/tests/test_build_components/build_components_base.esp8266-ard.yaml b/tests/test_build_components/build_components_base.esp8266-ard.yaml index 1e2d6143921..a20aa573aec 100644 --- a/tests/test_build_components/build_components_base.esp8266-ard.yaml +++ b/tests/test_build_components/build_components_base.esp8266-ard.yaml @@ -4,6 +4,7 @@ esphome: esp8266: board: d1_mini_pro + toolchain: arduino logger: level: VERY_VERBOSE From b1d07759e20a8db4334e25cb8055f904ce9d4b16 Mon Sep 17 00:00:00 2001 From: Christoffer Andersson Date: Sun, 27 Sep 2026 14:42:36 +0200 Subject: [PATCH 597/759] [light] Resume the previous effect on off/on (#17791) Co-authored-by: J. Nick Koston --- esphome/components/light/__init__.py | 5 + esphome/components/light/light_call.cpp | 19 +++ esphome/components/light/light_call.h | 2 + esphome/components/light/light_state.h | 13 ++ esphome/core/defines.h | 1 + tests/components/light/common.yaml | 1 + .../fixtures/light_resume_effect.yaml | 47 ++++++ tests/integration/test_light_resume_effect.py | 147 ++++++++++++++++++ 8 files changed, 235 insertions(+) create mode 100644 tests/integration/fixtures/light_resume_effect.yaml create mode 100644 tests/integration/test_light_resume_effect.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9ed183ea206..b175620bbf4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -87,6 +87,7 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "light" CONF_GAMMA_TABLE_ID = "gamma_table_id" +CONF_RESUME_EFFECT = "resume_effect" @dataclass @@ -394,6 +395,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -524,6 +526,9 @@ async def setup_light_core_(light_var, config, output_var): # runtime actually understands. initial_state_config = config.get(CONF_INITIAL_STATE) initial_statements = await _initial_state_statements(initial_state_config) + if config[CONF_RESUME_EFFECT]: + cg.add_define("USE_LIGHT_RESUME_EFFECT") + cg.add(light_var.set_resume_effect(True)) restore_mode = config.get(CONF_RESTORE_MODE) restore_state_config = config.get(CONF_RESTORE_STATE) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4251565e855..f540d2f31f1 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -193,6 +193,11 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); +#ifdef USE_LIGHT_RESUME_EFFECT + // Snapshot before the adjustments below add flags of their own + const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0; +#endif // USE_LIGHT_RESUME_EFFECT + // Color mode check if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); @@ -334,6 +339,15 @@ LightColorValues LightCall::validate_() { // validate transition length/flash length/effect not used at the same time bool supports_transition = color_mode & ColorCapability::BRIGHTNESS; +#ifdef USE_LIGHT_RESUME_EFFECT + // A plain turn-on from off brings back the effect that was running when the light was turned off + if (this->parent_->resume_effect_ && plain_turn_on && !this->parent_->remote_values.is_on() && + this->parent_->previous_effect_index_ != 0) { + this->effect_ = this->parent_->previous_effect_index_; + this->set_flag_(FLAG_HAS_EFFECT); + } +#endif // USE_LIGHT_RESUME_EFFECT + // If effect is already active, remove effect start if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) { this->clear_flag_(FLAG_HAS_EFFECT); @@ -376,6 +390,11 @@ LightColorValues LightCall::validate_() { // Reason: When user turns off the light in frontend, the effect should also stop bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { +#ifdef USE_LIGHT_RESUME_EFFECT + // Remember what was running, including no effect, when a lit light is explicitly turned off + if (this->parent_->resume_effect_ && explicit_turn_off_request && this->parent_->remote_values.is_on()) + this->parent_->previous_effect_index_ = this->parent_->active_effect_index_; +#endif // USE_LIGHT_RESUME_EFFECT if (this->has_effect_()) { log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->clear_flag_(FLAG_HAS_EFFECT); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index e3352de7272..c9f6af7c916 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -215,6 +215,8 @@ class LightCall { FLAG_SAVE = 1 << 15, }; static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 + // Flags a plain turn-on may carry; any other flag means the caller asked for something specific + static constexpr uint16_t STATE_ONLY_FLAGS_MASK = FLAG_HAS_STATE | FLAG_PUBLISH | FLAG_SAVE; inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index f20fecc8dda..fcb2ca40c90 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -208,6 +208,11 @@ class LightState : public EntityBase, public Component { /// Set whether this light persists its state to preferences at all. void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } +#ifdef USE_LIGHT_RESUME_EFFECT + /// Set whether a plain turn-on restores the effect that was active when the light was turned off. + void set_resume_effect(bool resume_effect) { this->resume_effect_ = resume_effect; } +#endif // USE_LIGHT_RESUME_EFFECT + /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -392,6 +397,10 @@ class LightState : public EntityBase, public Component { /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py uint16_t active_effect_index_{}; +#ifdef USE_LIGHT_RESUME_EFFECT + /// The effect index that was active when the light was last turned off; shares the active index's word + uint16_t previous_effect_index_{0}; +#endif // USE_LIGHT_RESUME_EFFECT /// Whether the light value should be written in the next cycle. bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. @@ -402,6 +411,10 @@ class LightState : public EntityBase, public Component { /// True while the active transformer publishes current_values on an interval from loop(). bool transition_publish_enabled_ : 1 {false}; #endif +#ifdef USE_LIGHT_RESUME_EFFECT + /// Whether a plain turn-on restores the effect that was active when the light was turned off. + bool resume_effect_ : 1 {false}; +#endif // USE_LIGHT_RESUME_EFFECT }; } // namespace esphome::light diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 757e3cfe79d..1226847fcfc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -91,6 +91,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 15829b14638..c40cfd5e3a3 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -217,6 +217,7 @@ light: name: Binary Light output: light_test_binary restore_mode: RESTORE_AND_ON + resume_effect: true effects: - strobe: on_state: diff --git a/tests/integration/fixtures/light_resume_effect.yaml b/tests/integration/fixtures/light_resume_effect.yaml new file mode 100644 index 00000000000..e52dc1ea677 --- /dev/null +++ b/tests/integration/fixtures/light_resume_effect.yaml @@ -0,0 +1,47 @@ +esphome: + name: light-resume-effect + +host: + +api: + +logger: + level: DEBUG + +output: + - platform: template + id: light_output + type: float + write_action: + - logger.log: + format: "LIGHT_OUTPUT:%.2f" + args: [state] + +light: + - platform: monochromatic + name: "Test Light Resume" + id: test_light_resume + output: light_output + resume_effect: true + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms + - strobe: + name: "Strobe Effect" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms + - platform: monochromatic + name: "Test Light No Resume" + id: test_light_no_resume + output: light_output + resume_effect: false + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms diff --git a/tests/integration/test_light_resume_effect.py b/tests/integration/test_light_resume_effect.py new file mode 100644 index 00000000000..dce5feb9ab5 --- /dev/null +++ b/tests/integration/test_light_resume_effect.py @@ -0,0 +1,147 @@ +"""Integration test verifying light effect restore on/off cycles. + +Tests that when resume_effect is enabled, the previously active effect +is restored when the light is turned back on without explicit effect/color +parameters. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_resume_effect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """When resume_effect is enabled, effect persists after off/on cycle.""" + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light_resume = next(e for e in entities if e.object_id == "test_light_resume") + light_no_resume = next( + e for e in entities if e.object_id == "test_light_no_resume" + ) + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # Drain initial state burst + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait( + light: LightInfo, timeout: float = 5.0, **kwargs: Any + ) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = asyncio.get_running_loop().create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # Test 1: Start with Pulse effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + # Test 2: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 3: Turn on without specifying effect — should restore Pulse Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Pulse Effect", ( + "Effect should be restored when turning on without explicit effect parameter" + ) + + # Test 4: Switch to Strobe effect + state = await send_and_wait(light_resume, effect="Strobe Effect") + assert state.effect == "Strobe Effect" + + # Test 5: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 6: Turn on without effect — should restore Strobe Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Strobe Effect", ( + "Effect should be restored to Strobe after turning on" + ) + + # Test 7: Explicitly set effect to None while turning on — should not restore + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_resume, state=True, effect="None") + assert state.state is True + assert state.effect == "None", ( + "Explicit effect=None should override restoration" + ) + + # Test 8: Turn off, then on with explicit effect — should use explicit effect + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.effect == "Pulse Effect" + + # Test 9: a turn-on that asks for something specific does not restore, and the + # effect it replaced must not come back on a later plain off/on + state = await send_and_wait(light_resume, state=False) + assert state.state is False + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None", "A turn-on with brightness should not restore" + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "An effect dropped on an earlier cycle must not return" + ) + + # Test 10: a plain turn-on sent to a light that is already on never starts the + # remembered effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None" + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "A lit light must not pick up the remembered effect" + ) + + # Test 11: a turn-on that names a color mode is not plain, so it does not restore + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait( + light_resume, state=True, color_mode=ColorMode.BRIGHTNESS + ) + assert state.effect == "None", "A turn-on with a color mode should not restore" + + # Test 12: Turn on effect, then off, then on without effect — should not restore Pulse Effect + state = await send_and_wait(light_no_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + state = await send_and_wait(light_no_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_no_resume, state=True) + assert state.state is True + assert state.effect == "None", ( + "Effect should NOT be restored when resume_effect=false" + ) From 97f362a718effa2c476490c5bc8b204c6de64913 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:04:17 +1000 Subject: [PATCH 598/759] [mipi_spi] Fix reversion in dc/cs sequence for spi_16 (#19745) --- esphome/components/mipi_spi/mipi_spi.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 550e1998bb5..e97b0e3a310 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,20 +246,18 @@ 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) { - // 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->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +269,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); From a91023e87caa1fa9acea1b2adee276cb199b890c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 27 Sep 2026 16:14:37 +0200 Subject: [PATCH 599/759] [nrf52] allow to download mcumgr image if exists (#19769) thanks --- esphome/components/nrf52/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index be8768fd154..249c28ac5f9 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -514,15 +514,15 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] - if (build_dir / APP_IMAGE_PATH).is_file(): - types += [ - { - "title": "App update package", - "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", - "file": APP_IMAGE_PATH, - "download": f"app-{storage_json.name}.img", - }, - ] + if (build_dir / APP_IMAGE_PATH).is_file(): + types += [ + { + "title": "App update package", + "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", + "file": APP_IMAGE_PATH, + "download": f"app-{storage_json.name}.img", + }, + ] return types From 1f175c1942ecb5c47741314ada4f907856da8c5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 19:18:08 +0200 Subject: [PATCH 600/759] [nrf52] Fetch only the nRF Connect SDK projects a build needs (#19735) --- esphome/components/debug/__init__.py | 4 +- esphome/components/nrf52/__init__.py | 35 +- esphome/components/nrf52/clang_tidy.py | 55 +- esphome/components/nrf52/framework.py | 202 +++++++- esphome/components/openthread/__init__.py | 8 + .../components/zephyr_ble_server/__init__.py | 6 + .../components/zephyr_mcumgr/ota/__init__.py | 4 + tests/unit_tests/test_nrf52_build.py | 23 + tests/unit_tests/test_nrf52_framework.py | 471 ++++++++++++++++-- 9 files changed, 747 insertions(+), 61 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_build.py diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index a889d13329b..e4692dfa9aa 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -51,7 +52,8 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("HWINFO", True) # gdb thread support zephyr_add_prj_conf("DEBUG_THREAD_INFO", True) - # RTT + # RTT sources live in the segger project + include_west_project("segger") zephyr_add_prj_conf("USE_SEGGER_RTT", True) zephyr_add_prj_conf("RTT_CONSOLE", True) zephyr_add_prj_conf("LOG", True) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 249c28ac5f9..b32d4f075ea 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -832,6 +832,26 @@ def _copy_if_exists(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def _west_build_command( + python_executable: Path, board: str, build_dir: Path, source_dir: Path +) -> list[str]: + return [ + str(python_executable), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + "--", + # Only adds -DNDEBUG (Kconfig sets the optimization level); picolibc used to force it + "-DCMAKE_BUILD_TYPE=MinSizeRel", + ] + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: # The actual build is done by PlatformIO (the caller falls through to @@ -866,18 +886,9 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) - west_cmd = [ - str(paths["python_executable"]), - "-m", - "west", - "build", - "--pristine=auto", - "-b", - board, - "-d", - str(build_dir), - str(source_dir), - ] + west_cmd = _west_build_command( + paths["python_executable"], board, build_dir, source_dir + ) if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 2dd4b7bd09f..89272cd4066 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -20,9 +20,13 @@ commands. from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from pathlib import Path +from .framework import bluetooth_west_projects + TIDY_PROJECT_NAME = "esphome_tidy" # Analyzed against the native toolchain's default SDK version @@ -33,19 +37,36 @@ _TIDY_BOARD = "adafruit_itsybitsy_nrf52840" # app target emits a C++ compile command to harvest flags/includes from. _TIDY_MAIN_CPP = "int main() { return 0; }\n" -# Kconfig superset enabling every subsystem an ESPHome nrf52 component may -# use, so the compile commands carry all of their include paths. -_TIDY_PRJ_CONF = """\ + +@dataclass(frozen=True) +class _TidySubsystem: + """A subsystem's Kconfig and the west projects its headers come from.""" + + name: str + prj_conf: str + # A callable when the projects differ by SDK version + west_projects: tuple[str, ...] | Callable[[], tuple[str, ...]] = () + + +# Kconfig superset for the compile commands and the projects it needs +_TIDY_SUBSYSTEMS = ( + _TidySubsystem( + "base", + """\ CONFIG_CPP=y CONFIG_STD_CPP20=y CONFIG_REQUIRES_FULL_LIBCPP=y CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y CONFIG_ADC=y # posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the # Zephyr POSIX headers clash with the libc ones under analysis CONFIG_POSIX_API=y -#mcumgr begin +""", + ), + _TidySubsystem("bluetooth", "CONFIG_BT=y\n", bluetooth_west_projects), + _TidySubsystem( + "mcumgr", + """\ CONFIG_NET_BUF=y CONFIG_ZCBOR=y CONFIG_MCUMGR=y @@ -60,14 +81,23 @@ CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin +""", + ("mcuboot", "zcbor"), + ), + _TidySubsystem( + "zigbee", + """\ CONFIG_ZIGBEE=y CONFIG_CRYPTO=y CONFIG_NVS=y CONFIG_SETTINGS=y -#zigbee end -""" +""", + ), +) + +_TIDY_PRJ_CONF = "".join( + f"# {subsystem.name}\n{subsystem.prj_conf}" for subsystem in _TIDY_SUBSYSTEMS +) def _tidy_cmakelists(library_include_dirs: str) -> str: @@ -166,6 +196,7 @@ def _setup_core(work_dir: Path) -> None: from esphome.core import CORE from . import RECOMMENDED_SDK_NRF_VERSION + from .framework import include_west_project CORE.name = TIDY_PROJECT_NAME # config_path's parent is the data-dir root for per-run artifacts. The @@ -178,6 +209,10 @@ def _setup_core(work_dir: Path) -> None: CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( RECOMMENDED_SDK_NRF_VERSION ) + for subsystem in _TIDY_SUBSYSTEMS: + projects = subsystem.west_projects + for project in projects() if callable(projects) else projects: + include_west_project(project) def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: @@ -237,6 +272,8 @@ def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: "zephyr_generated_headers", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + # As in a real build, so NDEBUG is set + "-DCMAKE_BUILD_TYPE=MinSizeRel", ] if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 6e57c46c7e3..48af47e8ffc 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,7 @@ +from collections.abc import Iterator +import configparser +from contextlib import contextmanager +from dataclasses import dataclass, field import hashlib import logging import os @@ -7,6 +11,7 @@ import shutil import sys from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -258,7 +263,84 @@ def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: tmp.replace(uf2conv) -def _west_update(env_python_path: Path, framework_path: Path) -> bool: +# West projects every build needs; components add others with include_west_project() +DEFAULT_WEST_PROJECTS = ("cmsis", "hal_nordic", "nrfxlib", "zephyr") + +_KEY_NRF52 = "nrf52" +# The projects a finished install fetched +_WEST_PROJECTS_FILE = ".west_projects" + + +@dataclass +class _Nrf52Data: + west_projects: set[str] = field(default_factory=lambda: set(DEFAULT_WEST_PROJECTS)) + + +def _get_data() -> _Nrf52Data: + if _KEY_NRF52 not in CORE.data: + CORE.data[_KEY_NRF52] = _Nrf52Data() + return CORE.data[_KEY_NRF52] + + +def include_west_project(name: str) -> None: + """Fetch a west project left out by default; call from to_code().""" + _get_data().west_projects.add(name) + + +def bluetooth_west_projects() -> tuple[str, ...]: + """Bluetooth's crypto: TinyCrypt up to SDK 3.1, PSA (mbedtls, Oberon) from 3.2.""" + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 2, 0): + return ("mbedtls", "oberon-psa-crypto") + return ("tinycrypt",) + + +def _wanted_west_projects() -> set[str]: + projects = set(_get_data().west_projects) + # Zephyr 4.1 moved the Cortex-M core headers to cmsis_6 + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 1, 0): + projects.add("cmsis_6") + # Sysbuild builds the MCUboot image with any bootloader + if CORE.data.get(KEY_ZEPHYR, {}).get(KEY_SYSBUILD): + projects.add("mcuboot") + return projects + + +def _set_project_filter( + env_python_path: Path, framework_path: Path, projects: set[str] +) -> bool: + # "--" keeps west from reading the leading "-" as an option + project_filter = ",".join(["-.*", *(f"+{p}" for p in sorted(projects))]) + cmd = [str(env_python_path), "-m", "west", "config", "manifest.project-filter"] + return run_command_ok([*cmd, "--", project_filter], cwd=framework_path) + + +def _check_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Raise when the manifest lacks one of ``projects``; needs zephyr cloned. + + west update quietly skips an unknown name in the filter, west list fails on it. + """ + names = sorted(projects) + cmd = [str(env_python_path), "-m", "west", "list", "-f", "{name}", *names] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError( + f"west list failed for the requested nRF Connect SDK {version} projects " + f"({', '.join(names)}); a project the manifest does not have is the " + "usual cause, see west's output above" + ) + + +def _west_update( + env_python_path: Path, + framework_path: Path, + version: str, + projects: set[str], + checked: bool = False, +) -> bool: + """Fetch ``projects``; False when the fetch fails.""" + if not _set_project_filter(env_python_path, framework_path, projects): + return False cmd = [ str(env_python_path), "-m", @@ -267,12 +349,103 @@ def _west_update(env_python_path: Path, framework_path: Path) -> bool: "--narrow", "--fetch-opt=--depth=1", ] - # Streamed so the per-project progress of the long clone reaches the log - return run_command_ok(cmd, cwd=framework_path, stream_output=True) + # Streamed so the clone's progress reaches the log + if not run_command_ok(cmd, cwd=framework_path, stream_output=True): + return False + if not checked: + _check_west_projects(env_python_path, framework_path, version, projects) + (framework_path / _WEST_PROJECTS_FILE).write_text( + "\n".join(sorted(projects)), encoding="utf-8" + ) + return True + + +def _installed_west_projects(framework_path: Path) -> set[str] | None: + """The projects a finished install fetched; None when it has every project.""" + try: + stamp = (framework_path / _WEST_PROJECTS_FILE).read_text(encoding="utf-8") + except FileNotFoundError: + pass + else: + return set(stamp.split()) + # No stamp: an install from before the filter has every project, a filtered + # one that lost its stamp fetches again + config = configparser.ConfigParser() + if not config.read(framework_path / ".west" / "config", encoding="utf-8"): + return set() + if config.has_option("manifest", "project-filter"): + return set() + return None + + +def _restore_project_filter( + env_python_path: Path, framework_path: Path, version: str, installed: set[str] +) -> None: + """Put the filter back to the stamp's projects; the defaults always stay in.""" + projects = installed | set(DEFAULT_WEST_PROJECTS) + if not _set_project_filter(env_python_path, framework_path, projects): + _LOGGER.warning( + "Couldn't put the nRF Connect SDK %s project filter back; " + "the next build that fetches a project sets it again", + version, + ) + + +# Lock wait slices, so Ctrl-C stays responsive +_INSTALL_LOCK_POLL = 1 + + +@contextmanager +def _install_lock(name: str) -> Iterator[None]: + """Serialize a shared install step across builds running at once.""" + from filelock import FileLock, Timeout + + lock_path = get_sdk_nrf_tools_path() / f"{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + # No soft lock: its marker outlives a killed build and hangs every later one + lock = FileLock(str(lock_path), fallback_to_soft=False) + waiting = False + while True: + try: + lock.acquire(timeout=_INSTALL_LOCK_POLL) + break + except Timeout: # before OSError, which it subclasses + if not waiting: + waiting = True + _LOGGER.info("Waiting for another build installing %s ...", name) + except OSError as err: + _LOGGER.warning( + "Can't lock %s (%s), continuing without a lock", lock_path, err + ) + break + try: + yield + finally: + lock.release() + + +def _fetch_missing_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Fetch the wanted projects a finished install lacks; it only ever gains.""" + if projects <= set(DEFAULT_WEST_PROJECTS): + return + installed = _installed_west_projects(framework_path) + # Before the fetch, so an unknown name costs nothing on any install + _check_west_projects(env_python_path, framework_path, version, projects) + if installed is None or not (missing := projects - installed): + return + _LOGGER.info( + "Fetching nRF Connect SDK %s projects: %s", version, ", ".join(sorted(missing)) + ) + wanted = installed | projects + if not _west_update(env_python_path, framework_path, version, wanted, checked=True): + _restore_project_filter(env_python_path, framework_path, version, installed) + raise EsphomeError(f"Can't update nRF Connect SDK {version}") def _install_framework( - env_python_path: Path, framework_path: Path, version: str + env_python_path: Path, framework_path: Path, version: str, projects: set[str] ) -> None: """Clone the nRF Connect SDK into ``framework_path`` with west. @@ -289,7 +462,7 @@ def _install_framework( initialized = (framework_path / ".west" / "config").is_file() if initialized and not (framework_path / ".ready").exists(): _LOGGER.info("Resuming the nRF Connect SDK %s download ...", version) - if _west_update(env_python_path, framework_path): + if _west_update(env_python_path, framework_path, version, projects): resume_failed.unlink(missing_ok=True) return if not resume_failed.exists(): @@ -318,12 +491,17 @@ def _install_framework( if not run_command_ok(cmd, stream_output=True): raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) - if not _west_update(env_python_path, framework_path): + if not _west_update(env_python_path, framework_path, version, projects): raise EsphomeError(f"Can't update nRF Connect SDK {version}") def check_and_install() -> None: version = _get_version_str() + with _install_lock(f"sdk-{version}"): + _check_and_install(version) + + +def _check_and_install(version: str) -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" @@ -354,12 +532,15 @@ def check_and_install() -> None: framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + projects = _wanted_west_projects() if not sentinel.exists() or not zephyr_reqs.exists(): - _install_framework(env_python_path, framework_path, version) + _install_framework(env_python_path, framework_path, version, projects) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() + else: + _fetch_missing_west_projects(env_python_path, framework_path, version, projects) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( @@ -380,6 +561,13 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() + # Shared by every SDK version; locked only while missing + if not (_get_toolchain_path(TOOLCHAIN_VERSION) / ".ready").exists(): + with _install_lock(f"toolchain-{TOOLCHAIN_VERSION}"): + _install_toolchain() + + +def _install_toolchain() -> None: toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 907200d5ec2..ac14efc8e68 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -19,6 +19,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage from esphome.components.network import add_use_address +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -30,6 +31,8 @@ from esphome.const import ( CONF_LOG_LEVEL, CONF_OUTPUT_POWER, CONF_USE_ADDRESS, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PlatformFramework, ) @@ -317,6 +320,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) elif CORE.using_zephyr: + # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 + include_west_project("mbedtls") + include_west_project("openthread") + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 7, 0): + include_west_project("oberon-psa-crypto") zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) zephyr_add_prj_conf( f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 59e5bc556bf..a012e832c7e 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,5 +1,9 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.nrf52.framework import ( + bluetooth_west_projects, + include_west_project, +) from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework @@ -36,6 +40,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) + for project in bluetooth_west_projects(): + include_west_project(project) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ad89c1ac794..109d1f7dd69 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( HexValue, @@ -115,6 +116,9 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_BUF", True) zephyr_add_prj_conf("ZCBOR", True) + include_west_project("zcbor") + # The image manager includes MCUboot headers with any bootloader + include_west_project("mcuboot") zephyr_add_prj_conf("MCUMGR", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG", True) diff --git a/tests/unit_tests/test_nrf52_build.py b/tests/unit_tests/test_nrf52_build.py new file mode 100644 index 00000000000..868bc9549f2 --- /dev/null +++ b/tests/unit_tests/test_nrf52_build.py @@ -0,0 +1,23 @@ +"""Tests for the nRF52 native west build command.""" + +from pathlib import Path + +from esphome.components.nrf52 import _west_build_command + + +def test_west_build_sets_the_cmake_build_type() -> None: + """The picolibc module used to force MinSizeRel, and with it the -DNDEBUG + that keeps libc assert() out of the image; the build sets it itself now.""" + python = Path("/penv/python") + cmd = _west_build_command( + python, + "adafruit_feather_nrf52840", + Path("/build/pio"), + Path("/build/zephyr"), + ) + + # str(), not a literal: the separator differs on Windows + assert cmd[:4] == [str(python), "-m", "west", "build"] + assert "-b" in cmd and "adafruit_feather_nrf52840" in cmd + # CMake arguments come after west's own + assert cmd[cmd.index("--") + 1 :] == ["-DCMAKE_BUILD_TYPE=MinSizeRel"] diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 05805a1d57f..d6b27bdbef2 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,5 +1,6 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import errno import hashlib import os from pathlib import Path @@ -14,16 +15,20 @@ from esphome.components.nrf52 import _resolve_toolchain from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, + DEFAULT_WEST_PROJECTS, TOOLCHAIN_VERSION, _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, _needs_venv_rebuild, + _wanted_west_projects, check_and_install, get_build_env, get_sdk_nrf_tools_path, + include_west_project, setup_platformio_python_env, ) +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain @@ -76,6 +81,8 @@ def test_get_toolchain_platform_info( # --------------------------------------------------------------------------- _TEST_SDK_VERSION = "2.9.0" +# The filter that keeps only DEFAULT_WEST_PROJECTS +_DEFAULTS_FILTER = "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" @pytest.fixture @@ -142,6 +149,33 @@ def _touch_penv_python(penv: Path) -> None: python.touch() +def _subcommand(cmd: list[str]) -> str: + tool = "west" if "west" in cmd else "pip" + return cmd[cmd.index(tool) + 1] + + +def _subcommands(run_command_ok) -> list[str]: + """The west or pip subcommand of each command run, in order.""" + return [_subcommand(c.args[0]) for c in run_command_ok.call_args_list] + + +def _project_filter(run_command_ok) -> str: + """The manifest.project-filter value of the last ``west config`` run.""" + for west_call in reversed(run_command_ok.call_args_list): + cmd = west_call.args[0] + if "manifest.project-filter" in cmd: + return cmd[-1] + raise AssertionError("no west config command ran") + + +def _mark_installed(dirs: SimpleNamespace) -> None: + """Every install step finished: venv, zephyr requirements, SDK, toolchain.""" + _mark_venv_ready(dirs.python_env) + (dirs.python_env / ".zephyr_reqs_ready").touch() + (dirs.framework / ".ready").touch() + (dirs.toolchain / ".ready").touch() + + def _mark_venv_ready(python_env: Path) -> None: """Write the venv sentinel with the current requirements hash and a present interpreter so the rebuild gate passes.""" @@ -157,10 +191,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - _mark_venv_ready(nrf52_dirs.python_env) - (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() - (nrf52_dirs.framework / ".ready").touch() - (nrf52_dirs.toolchain / ".ready").touch() + _mark_installed(nrf52_dirs) check_and_install() @@ -195,8 +226,14 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install requirements, west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 4 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "install", # requirements + "init", + "config", + "update", + "list", + "install", # zephyr requirements + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 assert mock_nrf52_ops.archive_extract_all.call_count == 2 @@ -216,8 +253,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 3 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + "install", + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 @@ -249,16 +291,12 @@ class TestCheckAndInstall: check_and_install() - init_cmd, update_cmd = ( - call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2] - ) - assert "init" in init_cmd - assert "-o=--depth=1" in init_cmd - assert "update" in update_cmd - assert "--fetch-opt=--depth=1" in update_cmd + init, _, update = mock_nrf52_ops.run_command_ok.call_args_list[:3] + assert "-o=--depth=1" in init.args[0] + assert "--fetch-opt=--depth=1" in update.args[0] # Streamed, so the long clone's progress reaches the log - for west_call in mock_nrf52_ops.run_command_ok.call_args_list[:2]: - assert west_call.kwargs["stream_output"] is True + assert init.kwargs["stream_output"] is True + assert update.kwargs["stream_output"] is True def test_interrupted_download_resumes( self, @@ -280,12 +318,15 @@ class TestCheckAndInstall: not in mock_nrf52_ops.rmdir.call_args_list ) assert not (nrf52_dirs.framework / ".resume_failed").exists() - # west update (no init), then pip install zephyr reqs - first = mock_nrf52_ops.run_command_ok.call_args_list[0] - assert "update" in first.args[0] - assert "init" not in first.args[0] - assert first.kwargs["cwd"] == nrf52_dirs.framework - assert mock_nrf52_ops.run_command_ok.call_count == 2 + # west update in the workspace (no init), then pip install zephyr reqs + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "list", + "install", + ] + update = mock_nrf52_ops.run_command_ok.call_args_list[1] + assert update.kwargs["cwd"] == nrf52_dirs.framework assert (nrf52_dirs.framework / ".ready").exists() def test_failed_resume_keeps_the_download_once( @@ -297,7 +338,8 @@ class TestCheckAndInstall: dropped again) and is retried on the next build.""" _mark_venv_ready(nrf52_dirs.python_env) _mark_west_initialized(nrf52_dirs.framework) - mock_nrf52_ops.run_command_ok.return_value = False + # config succeeds, the resumed update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] with pytest.raises(EsphomeError, match="Can't resume"): check_and_install() @@ -332,16 +374,21 @@ class TestCheckAndInstall: _mark_venv_ready(nrf52_dirs.python_env) _mark_west_initialized(nrf52_dirs.framework) (nrf52_dirs.framework / ".resume_failed").touch() - # resumed update fails; clean init, update and zephyr reqs succeed - mock_nrf52_ops.run_command_ok.side_effect = [False, True, True, True] + # resumed update fails; the clean clone and zephyr reqs succeed + mock_nrf52_ops.run_command_ok.side_effect = [True, False, *[True] * 5] check_and_install() mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) - commands = [c.args[0] for c in mock_nrf52_ops.run_command_ok.call_args_list] - assert "update" in commands[0] - assert "init" in commands[1] - assert "update" in commands[2] + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "init", + "config", + "update", + "list", + "install", + ] def test_requirements_install_failure_raises( self, @@ -373,12 +420,372 @@ class TestCheckAndInstall: ) -> None: """Failing west update raises EsphomeError.""" _mark_venv_ready(nrf52_dirs.python_env) - # init succeeds, update fails - mock_nrf52_ops.run_command_ok.side_effect = [True, False] + # init and config succeed, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False] with pytest.raises(EsphomeError, match="Can't update"): check_and_install() + def test_fresh_install_fetches_only_default_projects( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A fresh clone leaves every west project out except the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == ( + "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" + ) + stamp = nrf52_dirs.framework / ".west_projects" + assert stamp.read_text(encoding="utf-8").split() == sorted( + DEFAULT_WEST_PROJECTS + ) + + def test_included_project_joins_the_filter( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A project a component includes is fetched with the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("oberon-psa-crypto") + + check_and_install() + + assert "+oberon-psa-crypto" in _project_filter( + mock_nrf52_ops.run_command_ok + ).split(",") + + def test_installed_sdk_fetches_a_newly_needed_project( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A finished install gains a project another config left out, keeping + what it already has.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").write_text( + "cmsis\nhal_nordic\nnrfxlib\ntinycrypt\nzephyr", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + # The names are checked before anything is fetched + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + wanted = "-.*,+cmsis,+hal_nordic,+nrfxlib,+openthread,+tinycrypt,+zephyr" + assert _project_filter(mock_nrf52_ops.run_command_ok) == wanted + mock_nrf52_ops.rmdir.assert_not_called() + + @pytest.mark.parametrize( + "stamp", + [ + pytest.param(None, id="install_from_before_the_filter"), + pytest.param( + "cmsis\nhal_nordic\nnrfxlib\nopenthread\nzephyr", + id="project_already_fetched", + ), + ], + ) + def test_installed_sdk_with_the_project_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + stamp: str | None, + ) -> None: + """No fetch when the install already has every wanted project; an + install without the stamp (west config, no filter) has them all. The + names are still checked, which only reads the manifest.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + if stamp is not None: + (nrf52_dirs.framework / ".west_projects").write_text( + stamp, encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + def test_sysbuild_fetches_mcuboot( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Sysbuild always builds the MCUboot image, so it needs the project.""" + _mark_venv_ready(nrf52_dirs.python_env) + CORE.data[KEY_ZEPHYR] = {KEY_SYSBUILD: True} + + check_and_install() + + assert "+mcuboot" in _project_filter(mock_nrf52_ops.run_command_ok).split(",") + + @pytest.mark.parametrize( + ("sdk_version", "has_cmsis_6"), + [("2.9.2", False), ("3.1.0", True), ("3.2.0", True)], + ) + def test_sdk_3_1_and_later_want_cmsis_6( + self, setup_core: Path, sdk_version: str, has_cmsis_6: bool + ) -> None: + """Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)} + + assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6 + + def test_default_projects_never_read_the_stamp( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A build wanting only the defaults has them on any install; an + unreadable stamp shows the check never looked.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").mkdir() + + check_and_install() + + mock_nrf52_ops.run_command_ok.assert_not_called() + + def test_failed_project_fetch_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A failed fetch of a newly needed project raises, keeps the stamp and + puts the workspace filter back to the stamp's projects and the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + stamp = nrf52_dirs.framework / ".west_projects" + stamp.write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert stamp.read_text(encoding="utf-8") == "zephyr" + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + "config", + ] + # The defaults always stay in the restored filter + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_fetch_with_a_lost_stamp_keeps_the_defaults_active( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An unknown installed set must not leave a filter with every module off.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_filter_restore_is_logged( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """When the filter can't be put back after a failed fetch, the user is told.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config fails too + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert "Couldn't put the nRF Connect SDK" in caplog.text + + def test_lost_stamp_on_a_filtered_install_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace with a project filter but no stamp is a filtered install + whose record was lost, so the wanted projects are fetched, not assumed.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + + def test_install_waits_for_another_build_holding_the_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A held lock is waited on, never mistaken for a filesystem that + cannot lock (filelock's Timeout is an OSError as well).""" + from filelock import Timeout + + _mark_installed(nrf52_dirs) + + with ( + caplog.at_level("INFO"), + patch("filelock.FileLock") as file_lock, + ): + file_lock.return_value.acquire.side_effect = [Timeout("install.lock"), None] + check_and_install() + + assert file_lock.return_value.acquire.call_count == 2 + assert "Waiting for another build" in caplog.text + assert "continuing without a lock" not in caplog.text + + def test_install_from_before_the_filter_still_checks_the_names( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An install with every project fetches nothing, but an unknown name + is still rejected so a mistake shows on every install alike.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + mock_nrf52_ops.run_command_ok.reset_mock() + mock_nrf52_ops.run_command_ok.return_value = False + include_west_project("no_such_project") + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + def test_install_lock_is_per_sdk_version( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Installs of different SDK versions do not wait on each other; the + toolchain they share is only locked while it is missing.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + check_and_install() + + lock_files = [Path(c.args[0]).name for c in file_lock.call_args_list] + assert lock_files == [f"sdk-v{_TEST_SDK_VERSION}.lock"] + + def test_install_runs_unlocked_where_the_filesystem_cannot_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """No soft-lock fallback: its marker outlives a killed build and would + hang every later one, so the install goes ahead without a lock.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + file_lock.return_value.acquire.side_effect = OSError( + errno.ENOSYS, "Function not implemented" + ) + check_and_install() + + assert file_lock.call_args.kwargs == {"fallback_to_soft": False} + assert "continuing without a lock" in caplog.text + + def test_unknown_project_raises_before_anything_is_written( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A filter naming a project the manifest lacks fetches nothing, so + the names are checked once the update resolved the manifest, and an + unknown one is never recorded as installed.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("no_such_project") + # init, config and update succeed, list fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, True, False] + + with pytest.raises(EsphomeError, match="west list failed .*no_such_project"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + ] + assert "no_such_project" in mock_nrf52_ops.run_command_ok.call_args.args[0] + assert not (nrf52_dirs.framework / ".west_projects").exists() + + def test_unknown_project_on_an_installed_sdk_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """On a finished install the names are checked first, so an unknown one + costs no fetch and leaves the workspace as it was.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("no_such_project") + # list fails before anything is fetched or changed + mock_nrf52_ops.run_command_ok.side_effect = [False] + + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + assert (nrf52_dirs.framework / ".west_projects").read_text( + encoding="utf-8" + ) == "zephyr" + + def test_missing_stamp_and_west_config_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """With neither the stamp nor .west/config left, nothing says what the + install has, so the wanted projects are fetched rather than assumed.""" + _mark_installed(nrf52_dirs) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + def test_toolchain_download_passes_platform_substitutions( self, nrf52_dirs: SimpleNamespace, From 7cda9a2666aef81c5b1b813c88236c07673da300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 19:19:10 +0200 Subject: [PATCH 601/759] [core][sensor][binary_sensor][text_sensor][button][switch][number][select][debug][xiaomi_rtcgq02lm][tfluna][ld2412] Bind sub entity config and parent once with sub_* helpers (#19754) --- AGENTS.md | 19 +++-- esphome/components/binary_sensor/__init__.py | 20 +++--- esphome/components/button/__init__.py | 10 ++- esphome/components/debug/sensor.py | 25 +++---- esphome/components/debug/text_sensor.py | 11 ++- esphome/components/ld2412/binary_sensor.py | 30 +++----- esphome/components/ld2412/button/__init__.py | 28 +++----- esphome/components/ld2412/number/__init__.py | 71 ++++++++++--------- esphome/components/ld2412/select/__init__.py | 64 ++++++++--------- esphome/components/ld2412/sensor.py | 31 +++----- esphome/components/ld2412/switch/__init__.py | 13 ++-- esphome/components/ld2412/text_sensor.py | 11 ++- esphome/components/number/__init__.py | 13 +++- esphome/components/select/__init__.py | 11 ++- esphome/components/sensor/__init__.py | 20 +++--- esphome/components/switch/__init__.py | 13 +++- esphome/components/text_sensor/__init__.py | 21 +++--- esphome/components/tfluna/button/__init__.py | 13 ++-- esphome/components/tfluna/sensor.py | 19 ++--- esphome/components/tfluna/text_sensor.py | 7 +- .../xiaomi_rtcgq02lm/binary_sensor.py | 17 ++--- esphome/components/xiaomi_rtcgq02lm/sensor.py | 5 +- esphome/core/entity_helpers.py | 39 ++++++---- tests/unit_tests/core/test_entity_helpers.py | 57 ++++++++++----- 24 files changed, 281 insertions(+), 287 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 670cf55b039..6a60fc47b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,14 +322,23 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` - - **Optional child entities of a hub:** use `new_sub_sensor`, `new_sub_binary_sensor` or - `new_sub_text_sensor`. Each creates the entity only when its key is configured and passes it to - the setter. Always name the setter explicitly; never build it with `getattr` and an f-string. + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or + `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, + `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, + then make one call per key, even when there is only one. A call creates the entity only when its key + is configured, passes it to the setter and returns it (or `None`); extra arguments such as + `min_value` or `options` go on the call. Always name the setter explicitly on the object that owns + it, never with `getattr` and an f-string, and keep that variable short (`var` for the component + itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose + setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. ```python async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await sensor.new_sub_sensor(config, CONF_TEMPERATURE, var.set_temperature_sensor) - await sensor.new_sub_sensor(config, CONF_HUMIDITY, var.set_humidity_sensor) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + buttons = button.sub_buttons(config, parent=var) + await buttons(CONF_RESTART, var.set_restart_button) ``` * **Automations (Triggers, Actions, Conditions):** diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index fb2ade82ce1..f5cde2c8e6f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from logging import getLogger from esphome import automation, core @@ -62,16 +61,16 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -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 ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, Expression, SafeExpType +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -635,14 +634,11 @@ async def new_binary_sensor(config, *args): return var -async def new_sub_binary_sensor( - config: ConfigType, - key: str, - setter: Callable[[MockObj], Expression], - *args: SafeExpType, -) -> MockObj | None: - """Create the binary sensor configured under key, if any, and pass it to setter.""" - return await new_sub_entity(new_binary_sensor, config, key, setter, *args) +def sub_binary_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_binary_sensor.""" + return SubEntities(new_binary_sensor, config, parent) BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index bdb438a99de..342f4515093 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,8 +16,9 @@ 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 ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, @@ -114,6 +115,13 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +def sub_buttons( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_button.""" + return SubEntities(new_button, config, parent) + + BUTTON_PRESS_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Button), diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 35eba48d13e..07c67a2b937 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -106,20 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - await sensor.new_sub_sensor(config, CONF_FREE, debug_component.set_free_sensor) - await sensor.new_sub_sensor(config, CONF_BLOCK, debug_component.set_block_sensor) - await sensor.new_sub_sensor( - config, CONF_FRAGMENTATION, debug_component.set_fragmentation_sensor - ) - await sensor.new_sub_sensor( - config, CONF_MIN_FREE, debug_component.set_min_free_sensor - ) - await sensor.new_sub_sensor( - config, CONF_LOOP_TIME, debug_component.set_loop_time_sensor - ) - await sensor.new_sub_sensor(config, CONF_PSRAM, debug_component.set_psram_sensor) - await sensor.new_sub_sensor( - config, CONF_CPU_FREQUENCY, debug_component.set_cpu_frequency_sensor - ) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index cac7da0f232..43ed0d15c62 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -31,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - await text_sensor.new_sub_text_sensor( - config, CONF_DEVICE, debug_component.set_device_info_sensor - ) - await text_sensor.new_sub_text_sensor( - config, CONF_RESET_REASON, debug_component.set_reset_reason_sensor - ) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 80cff014c0d..9e937275499 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -50,24 +50,12 @@ CONFIG_SCHEMA = { 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 - ): - sens = await binary_sensor.new_binary_sensor( - dynamic_background_correction_status_config - ) - cg.add( - LD2412_component.set_dynamic_background_correction_status_binary_sensor( - sens - ) - ) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(LD2412_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(LD2412_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(LD2412_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS, + hub.set_dynamic_background_correction_status_binary_sensor, + ) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 5a1ea2e6a5d..9f5c868310b 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -56,22 +56,12 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_factory_reset_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_query_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_restart_button(b)) - if start_dynamic_background_correction_config := config.get( - CONF_START_DYNAMIC_BACKGROUND_CORRECTION - ): - b = await button.new_button(start_dynamic_background_correction_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_start_dynamic_background_correction_button(b)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons( + CONF_START_DYNAMIC_BACKGROUND_CORRECTION, + hub.set_start_dynamic_background_correction_button, + ) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index f27e241491d..0c0c01c69cc 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -87,42 +87,45 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_threshold_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): - n = await number.new_number( - max_distance_gate_config, min_value=2, max_value=13, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_max_distance_gate_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): - n = await number.new_number( - min_distance_gate_config, min_value=1, max_value=12, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_min_distance_gate_number(n)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) + await numbers( + CONF_MAX_DISTANCE_GATE, + hub.set_max_distance_gate_number, + min_value=2, + max_value=13, + step=1, + ) + await numbers( + CONF_MIN_DISTANCE_GATE, + hub.set_min_distance_gate_number, + min_value=1, + max_value=12, + step=1, + ) for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID]) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID]) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 02ecf2c30f5..40a075e5d54 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -50,36 +50,34 @@ CONFIG_SCHEMA = { 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( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_baud_rate_select(s)) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_distance_resolution_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_function_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_out_pin_level_select(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.5m", "0.75m"], + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + ) diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931d..e236a7d5f38 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -158,30 +158,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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) - cg.add(LD2412_component.set_detection_distance_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(LD2412_component.set_light_sensor(sens)) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(LD2412_component.set_moving_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(LD2412_component.set_moving_target_energy_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(LD2412_component.set_still_target_distance_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(LD2412_component.set_still_target_energy_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) for x in range(14): if gate_conf := config.get(f"gate_{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(LD2412_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(LD2412_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index e7f71222fdb..d0643055972 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_bluetooth_switch(s)) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_engineering_mode_switch(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index c8e9f42ef39..63856d9f002 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { 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) - cg.add(LD2412_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(LD2412_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index afa2fac483e..8edbdecee5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,16 +79,18 @@ from esphome.const import ( DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -325,6 +327,13 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +def sub_numbers( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_number.""" + return SubEntities(new_number, config, parent) + + NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Number), diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 6b7461bfa8d..ae78e729e6e 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -20,12 +20,14 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass, TemplateArguments +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArguments from esphome.cpp_types import global_ns +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -123,6 +125,13 @@ async def new_select(config, *args, options: list[str]): return var +def sub_selects( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_select.""" + return SubEntities(new_select, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5df4a9acbdc..f3293d4ed56 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,4 +1,3 @@ -from collections.abc import Callable import logging import math @@ -112,11 +111,11 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, 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.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, @@ -124,7 +123,7 @@ from esphome.core.entity_helpers import ( ) from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, Expression, SafeExpType +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -1016,14 +1015,11 @@ async def new_sensor(config, *args): return var -async def new_sub_sensor( - config: ConfigType, - key: str, - setter: Callable[[MockObj], Expression], - *args: SafeExpType, -) -> MockObj | None: - """Create the sensor configured under key, if any, and pass it to setter.""" - return await new_sub_entity(new_sensor, config, key, setter, *args) +def sub_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_sensor.""" + return SubEntities(new_sensor, config, parent) SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index d6cd061e040..c34a73a36d1 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH, ) -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 ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -173,6 +175,13 @@ async def new_switch(config, *args): return var +def sub_switches( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_switch.""" + return SubEntities(new_switch, config, parent) + + SWITCH_ACTION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Switch), diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 392fe325d57..08870db64aa 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -1,5 +1,3 @@ -from collections.abc import Callable - from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -22,16 +20,16 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_TIMESTAMP, ) -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 ( + SubEntities, entity_duplicate_validator, - new_sub_entity, queue_entity_register, setup_device_class, setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, Expression, SafeExpType +from esphome.types import ConfigType from esphome.util import Registry DEVICE_CLASSES = [ @@ -235,14 +233,11 @@ async def new_text_sensor(config, *args): return var -async def new_sub_text_sensor( - config: ConfigType, - key: str, - setter: Callable[[MockObj], Expression], - *args: SafeExpType, -) -> MockObj | None: - """Create the text sensor configured under key, if any, and pass it to setter.""" - return await new_sub_entity(new_text_sensor, config, key, setter, *args) +def sub_text_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_text_sensor.""" + return SubEntities(new_text_sensor, config, parent) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py index deb2ba8ffb3..753ac7de333 100644 --- a/esphome/components/tfluna/button/__init__.py +++ b/esphome/components/tfluna/button/__init__.py @@ -61,12 +61,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py index d101d12ae01..3fb9ed20a66 100644 --- a/esphome/components/tfluna/sensor.py +++ b/esphome/components/tfluna/sensor.py @@ -60,16 +60,9 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(tfluna_component.set_distance_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(tfluna_component.set_temperature_sensor(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(tfluna_component.set_signal_strength_sensor(sens)) - if timestamp_config := config.get(CONF_TIMESTAMP): - sens = await sensor.new_sensor(timestamp_config) - cg.add(tfluna_component.set_timestamp_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_SIGNAL_STRENGTH, hub.set_signal_strength_sensor) + await sensors(CONF_TIMESTAMP, hub.set_timestamp_sensor) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py index 6bce14252b0..6844f3a7267 100644 --- a/esphome/components/tfluna/text_sensor.py +++ b/esphome/components/tfluna/text_sensor.py @@ -17,7 +17,6 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(tfluna_component.set_version_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index f1b01cfd61f..c122b1ce983 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -47,14 +47,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if await binary_sensor.new_sub_binary_sensor( - config, CONF_MOTION, parent.set_motion - ): - cg.add(parent.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) - await binary_sensor.new_sub_binary_sensor(config, CONF_LIGHT, parent.set_light) - if await binary_sensor.new_sub_binary_sensor( - config, CONF_BUTTON, parent.set_button - ): - cg.add(parent.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) + binary_sensors = binary_sensor.sub_binary_sensors(config) + if await binary_sensors(CONF_MOTION, hub.set_motion): + cg.add(hub.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) + await binary_sensors(CONF_LIGHT, hub.set_light) + if await binary_sensors(CONF_BUTTON, hub.set_button): + cg.add(hub.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index 587b1b594ea..5e849b26bc5 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -31,6 +31,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - await sensor.new_sub_sensor(config, CONF_BATTERY_LEVEL, parent.set_battery_level) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, hub.set_battery_level) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 2956ac60016..4f446050253 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -468,23 +468,32 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx -async def new_sub_entity( - new_entity: Callable[..., Awaitable[MockObj]], - config: ConfigType, - key: str, - setter: Callable[[MockObj], Expression], - *args: SafeExpType, - **kwargs: Any, -) -> MockObj | None: - """Create the entity configured under key, if any, and pass it to setter. +@dataclass(frozen=True, slots=True) +class SubEntities: + """Create the child entity configured under key, set parent (if bound) and pass it to setter. - Returns None only when key is not configured, so the result can be used directly as a condition. + Extra arguments go to new_entity. Returns None only when key is absent, so the result can be + used directly as a condition. """ - if (conf := config.get(key)) is None: - return None - var = await new_entity(conf, *args, **kwargs) - add(setter(var)) - return var + + new_entity: Callable[..., Awaitable[MockObj]] + config: ConfigType + parent: MockObj | ID | None = None + + async def __call__( + self, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, + ) -> MockObj | None: + if (conf := self.config.get(key)) is None: + return None + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + add(setter(var)) + return var def inherit_property_from(property_to_inherit, parent_id_property, transform=None): diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index b10ba1de27c..cd6b3315178 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1239,39 +1239,58 @@ async def test_finalize_comment_sanitization( @pytest.mark.asyncio -async def test_new_sub_entity_creates_and_sets( - setup_test_environment: list[str], +async def test_sub_entities_creates_and_sets( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] ) -> None: - """A configured key, even with an empty config, creates the entity and passes it to the setter.""" - sub_config: dict[str, Any] = {} - created = MockObj("child") + """A configured key creates the entity, sets the parent before the setter and returns it.""" calls: list[tuple[Any, ...]] = [] + created = MockObj("child") async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: calls.append((conf, args, kwargs)) return created - parent = MockObj("parent", "->") - result = await entity_helpers.new_sub_entity( - new_entity, {"child": sub_config}, "child", parent.set_child, 1, step=2 - ) + async def fake_register_parented(var: MockObj, parent: MockObj) -> None: + setup_test_environment.append(f"parent {var} {parent}") - assert result is created - assert calls == [(sub_config, (1,), {"step": 2})] - assert setup_test_environment == ["parent->set_child(child)"] + monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + + assert await sub("child", hub.set_child, 1, step=2) is created + assert calls == [({}, (1,), {"step": 2})] + assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] @pytest.mark.asyncio -async def test_new_sub_entity_missing_key(setup_test_environment: list[str]) -> None: +async def test_sub_entities_missing_key(setup_test_environment: list[str]) -> None: """A missing key creates nothing and emits no code.""" - async def new_entity(conf: dict[str, Any], *args: Any) -> MockObj: + async def new_entity(conf: dict[str, Any]) -> MockObj: raise AssertionError("must not be called") - parent = MockObj("parent", "->") - result = await entity_helpers.new_sub_entity( - new_entity, {}, "child", parent.set_child - ) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {}) - assert result is None + assert await sub("child", hub.set_child) is None assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_without_parent( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """Without a bound parent, register_parented is not called.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + async def new_entity(conf: dict[str, Any]) -> MockObj: + return MockObj("child") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {CONF_ID: "child"}}) + + assert await sub("child", hub.set_child) is not None + assert setup_test_environment == ["hub->set_child(child)"] From d34b09dd7dc52e5ae84c547aa75a214a9250cbdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:09:15 +0200 Subject: [PATCH 602/759] [ld2420] Use the sub_* helpers for optional child entities (#19776) --- .../ld2420/binary_sensor/__init__.py | 9 +- esphome/components/ld2420/button/__init__.py | 23 ++--- esphome/components/ld2420/number/__init__.py | 95 ++++++++++--------- esphome/components/ld2420/select/__init__.py | 13 +-- esphome/components/ld2420/sensor/__init__.py | 13 +-- .../components/ld2420/text_sensor/__init__.py | 9 +- 6 files changed, 76 insertions(+), 86 deletions(-) diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 76b42c03628..ca3891a97c0 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -27,8 +27,7 @@ CONFIG_SCHEMA = cv.All( 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: - sens = await binary_sensor.new_binary_sensor(config[CONF_HAS_TARGET]) - cg.add(var.set_presence_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, var.set_presence_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index cfcffd0922d..533f3b91aa3 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -52,20 +52,9 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_apply_config_button(b)) - if revert_config := config.get(CONF_REVERT_CONFIG): - b = await button.new_button(revert_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_revert_config_button(b)) - if restart_config := config.get(CONF_RESTART_MODULE): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_restart_module_button(b)) - if factory_reset := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_APPLY_CONFIG, hub.set_apply_config_button) + await buttons(CONF_REVERT_CONFIG, hub.set_revert_config_button) + await buttons(CONF_RESTART_MODULE, hub.set_restart_module_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index 448639c9112..200f4e202fb 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -115,56 +115,65 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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( - gate_timeout_config, min_value=0, max_value=255, step=5 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_timeout_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_GATE_DISTANCE): - n = await number.new_number( - min_distance_gate_config, min_value=0, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_min_gate_distance_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_GATE_DISTANCE): - n = await number.new_number( - max_distance_gate_config, min_value=1, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_max_gate_distance_number(n)) - if gate_move_sensitivity_config := config.get(CONF_GATE_MOVE_SENSITIVITY): - n = await number.new_number( - gate_move_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_sensitivity_factor_number(n)) - if gate_still_sensitivity_config := config.get(CONF_GATE_STILL_SENSITIVITY): - n = await number.new_number( - gate_still_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_sensitivity_factor_number(n)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_gate_timeout_number, + min_value=0, + max_value=255, + step=5, + ) + await numbers( + CONF_MIN_GATE_DISTANCE, + hub.set_min_gate_distance_number, + min_value=0, + max_value=15, + step=1, + ) + await numbers( + CONF_MAX_GATE_DISTANCE, + hub.set_max_gate_distance_number, + min_value=1, + max_value=15, + step=1, + ) + await numbers( + CONF_GATE_MOVE_SENSITIVITY, + hub.set_gate_move_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) + await numbers( + CONF_GATE_STILL_SENSITIVITY, + hub.set_gate_still_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) if config.get(CONF_GATE_SELECT): - if gate_number := config.get(CONF_GATE_SELECT): - n = await number.new_number(gate_number, min_value=0, max_value=15, step=1) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_select_number(n)) + await numbers( + CONF_GATE_SELECT, + hub.set_gate_select_number, + min_value=0, + max_value=15, + step=1, + ) if gate_still_threshold := config.get(CONF_STILL_THRESHOLD): n = cg.new_Pvariable(gate_still_threshold[CONF_ID]) await number.register_number( n, gate_still_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(0, n)) if gate_move_threshold := config.get(CONF_MOVE_THRESHOLD): n = cg.new_Pvariable(gate_move_threshold[CONF_ID]) await number.register_number( n, gate_move_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(0, n)) else: for x in range(16): if gate_conf := config.get(f"gate_{x}"): @@ -173,13 +182,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(x, n)) diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index cd66064e475..6f3c73b9d12 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -25,11 +25,8 @@ CONFIG_SCHEMA = { 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( - operating_mode_config, - options=CONF_SELECTS, - ) - await cg.register_parented(sel, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_operating_mode_select(sel)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_OPERATING_MODE, hub.set_operating_mode_select, options=CONF_SELECTS + ) diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index f98d63585b6..89cfd3a5153 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -34,11 +34,8 @@ CONFIG_SCHEMA = cv.All( 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: - sens = await sensor.new_sensor(config[CONF_MOVING_DISTANCE]) - cg.add(var.set_distance_sensor(sens)) - if CONF_GATE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_GATE_ENERGY]) - cg.add(var.set_energy_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, var.set_distance_sensor) + await sensors(CONF_GATE_ENERGY, var.set_energy_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index cee8f25c1fe..58ebc9cf5b1 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -28,8 +28,7 @@ CONFIG_SCHEMA = cv.All( 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: - sens = await text_sensor.new_text_sensor(config[CONF_FW_VERSION]) - cg.add(var.set_fw_version_text_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_FW_VERSION, var.set_fw_version_text_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) From 6072805950dab717b38fbe2a9d99fc50eefa44e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:42:44 +0200 Subject: [PATCH 603/759] [scd30] Use the sub_* helpers for optional child entities (#19794) --- esphome/components/scd30/sensor.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index aadc85ddb7e..f3f4ede8c93 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -99,17 +99,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) automation.register_apply_action( From 32c03f20797968c64de82dcfd1328c001c7eef31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:42:58 +0200 Subject: [PATCH 604/759] [xiaomi_body_scale] Use the sub_* helpers for optional child entities (#19772) --- .../components/xiaomi_body_scale/sensor.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py index c8cbc0e820a..cb0802d270a 100644 --- a/esphome/components/xiaomi_body_scale/sensor.py +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -78,16 +78,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) - for key, setter in ( - (CONF_WEIGHT, var.set_weight), - (CONF_IMPEDANCE_LOW, var.set_impedance_low), - (CONF_IMPEDANCE_HIGH, var.set_impedance_high), - (CONF_HEART_RATE, var.set_heart_rate), - (CONF_PROFILE_ID, var.set_profile_id), - ): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(setter(sens)) - if (conf := config.get(CONF_STABILIZED)) is not None: - sens = await binary_sensor.new_binary_sensor(conf) - cg.add(var.set_stabilized(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WEIGHT, var.set_weight) + await sensors(CONF_IMPEDANCE_LOW, var.set_impedance_low) + await sensors(CONF_IMPEDANCE_HIGH, var.set_impedance_high) + await sensors(CONF_HEART_RATE, var.set_heart_rate) + await sensors(CONF_PROFILE_ID, var.set_profile_id) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_STABILIZED, var.set_stabilized) From af13aa5555fd77bfb191561920846ea864886e78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:43:15 +0200 Subject: [PATCH 605/759] [growatt_solar] Use the sub_* helpers for optional child entities (#19779) --- esphome/components/growatt_solar/sensor.py | 35 +++++----------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 3728710b1f9..af323e6b07d 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -177,33 +177,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) - if CONF_INVERTER_STATUS in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_STATUS]) - cg.add(var.set_inverter_status_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_grid_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_grid_active_power_sensor(sens)) - - if CONF_PV_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_PV_ACTIVE_POWER]) - cg.add(var.set_pv_active_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_INVERTER_STATUS, var.set_inverter_status_sensor) + await sensors(CONF_FREQUENCY, var.set_grid_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_grid_active_power_sensor) + await sensors(CONF_PV_ACTIVE_POWER, var.set_pv_active_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: From f2cd2ba449f2938406fc026bb858c90a7f2c4174 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:55:58 +0200 Subject: [PATCH 606/759] [tcs34725] Use the sub_* helpers for optional child entities (#19797) --- esphome/components/tcs34725/sensor.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index da6edfd62ab..898aa945438 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -113,21 +113,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) - if CONF_RED_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_RED_CHANNEL]) - cg.add(var.set_red_sensor(sens)) - if CONF_GREEN_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_GREEN_CHANNEL]) - cg.add(var.set_green_sensor(sens)) - if CONF_BLUE_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_BLUE_CHANNEL]) - cg.add(var.set_blue_sensor(sens)) - if CONF_CLEAR_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_CLEAR_CHANNEL]) - cg.add(var.set_clear_sensor(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance_sensor(sens)) - if CONF_COLOR_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, var.set_red_sensor) + await sensors(CONF_GREEN_CHANNEL, var.set_green_sensor) + await sensors(CONF_BLUE_CHANNEL, var.set_blue_sensor) + await sensors(CONF_CLEAR_CHANNEL, var.set_clear_sensor) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, var.set_color_temperature_sensor) From 9fa14132f48d97dde6093b40b639047fefbcb985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:56:07 +0200 Subject: [PATCH 607/759] [ltr_als_ps] Use the sub_* helpers for optional child entities (#19780) --- esphome/components/ltr_als_ps/sensor.py | 30 ++++++------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 7bd476156ba..223528581e9 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -219,29 +219,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS): - sens = await sensor.new_sensor(infrared_cnt_config) - cg.add(var.set_infrared_counts_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_full_spectrum_counts_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) - - if prox_cnt_config := config.get(CONF_PS_COUNTS): - sens = await sensor.new_sensor(prox_cnt_config) - cg.add(var.set_proximity_counts_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) + await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) From 7d7c6177c02e28ca473ec1bfe0537b8b521a82e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:56:16 +0200 Subject: [PATCH 608/759] [hm3301] Use the sub_* helpers for optional child entities (#19795) --- esphome/components/hm3301/sensor.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 2fa82b2710a..79fb81ea5f5 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -92,17 +92,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) # Remove before 2026.12.0 if CONF_AQI in config: From 82c58da1f2f5ba0fe233c808f7655e2242b2bcfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:56:26 +0200 Subject: [PATCH 609/759] [atc_mithermometer] Use the sub_* helpers for optional child entities (#19787) --- .../components/atc_mithermometer/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 184b2e87339..cde1a41f657 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(var.set_battery_voltage(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) From 5adae2c978145e18c1aa6d8bdde8a525a6f2c9e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:56:40 +0200 Subject: [PATCH 610/759] [veml7700] Use the sub_* helpers for optional child entities (#19778) --- esphome/components/veml7700/sensor.py | 35 ++++++--------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 02820505edb..9ed98fc4dfd 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -160,33 +160,14 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if als_cnt_config := config.get(CONF_AMBIENT_LIGHT_COUNTS): - sens = await sensor.new_sensor(als_cnt_config) - cg.add(var.set_ambient_light_counts_sensor(sens)) - - if full_spect_config := config.get(CONF_FULL_SPECTRUM): - sens = await sensor.new_sensor(full_spect_config) - cg.add(var.set_white_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_white_counts_sensor(sens)) - - if infrared_config := config.get(CONF_INFRARED): - sens = await sensor.new_sensor(infrared_config) - cg.add(var.set_infrared_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_AMBIENT_LIGHT_COUNTS, var.set_ambient_light_counts_sensor) + await sensors(CONF_FULL_SPECTRUM, var.set_white_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_white_counts_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) cg.add(var.set_enable_automatic_mode(config[CONF_AUTO_MODE])) cg.add(var.set_enable_lux_compensation(config[CONF_LUX_COMPENSATION])) From 87c8dc703adf8c3673648da54190f19a3f3460dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:56:47 +0200 Subject: [PATCH 611/759] [seeed_mr60fda2] Use the sub_* helpers for optional child entities (#19799) --- .../seeed_mr60fda2/binary_sensor.py | 12 +++---- .../seeed_mr60fda2/button/__init__.py | 13 +++---- .../seeed_mr60fda2/select/__init__.py | 35 +++++++------------ 3 files changed, 21 insertions(+), 39 deletions(-) diff --git a/esphome/components/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 63bd02acd01..e1dc4289972 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -23,12 +23,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if people_exist_config := config.get(CONF_PEOPLE_EXIST): - sens = await binary_sensor.new_binary_sensor(people_exist_config) - cg.add(mr60fda2_component.set_people_exist_binary_sensor(sens)) - - if is_fall_config := config.get(CONF_FALL_DETECTED): - sens = await binary_sensor.new_binary_sensor(is_fall_config) - cg.add(mr60fda2_component.set_fall_detected_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PEOPLE_EXIST, hub.set_people_exist_binary_sensor) + await binary_sensors(CONF_FALL_DETECTED, hub.set_fall_detected_binary_sensor) diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 82f0fc9aeaf..582c72bebe9 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -35,12 +35,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_get_radar_parameters_button(b)) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_GET_RADAR_PARAMETERS, hub.set_get_radar_parameters_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 6d8864455f0..0b3359b71cd 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -35,25 +35,16 @@ CONFIG_SCHEMA = { 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( - install_height_config, - options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_install_height_select(s)) - if height_threshold_config := config.get(CONF_HEIGHT_THRESHOLD): - s = await select.new_select( - height_threshold_config, - options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_height_threshold_select(s)) - if sensitivity_config := config.get(CONF_SENSITIVITY): - s = await select.new_select( - sensitivity_config, - options=["1", "2", "3"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_sensitivity_select(s)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_INSTALL_HEIGHT, + hub.set_install_height_select, + options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], + ) + await selects( + CONF_HEIGHT_THRESHOLD, + hub.set_height_threshold_select, + options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], + ) + await selects(CONF_SENSITIVITY, hub.set_sensitivity_select, options=["1", "2", "3"]) From 03da54c2f58c19d5571dee43cdee6f6d88713761 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:01 +0200 Subject: [PATCH 612/759] [bl0906] Use the sub_* helpers for optional child entities (#19796) --- esphome/components/bl0906/sensor.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 67ff903fbf1..25c50555dbc 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -150,15 +150,10 @@ 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) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) for i in range(6): if channel_config := config.get(f"{CONF_CHANNEL}_{i + 1}"): @@ -172,10 +167,5 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(energy_config) cg.add(getattr(var, f"set_energy_{i + 1}_sensor")(sens)) - if total_power_config := config.get(CONF_TOTAL_POWER): - sens = await sensor.new_sensor(total_power_config) - cg.add(var.set_total_power_sensor(sens)) - - if total_energy_config := config.get(CONF_TOTAL_ENERGY): - sens = await sensor.new_sensor(total_energy_config) - cg.add(var.set_total_energy_sensor(sens)) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_TOTAL_ENERGY, var.set_total_energy_sensor) From 2bfd2bd4d7e9f32df6774af4b3450ee678cf481c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:06 +0200 Subject: [PATCH 613/759] [pvvx_mithermometer] Use the sub_* helpers for optional child entities (#19788) --- .../components/pvvx_mithermometer/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index ad13bdcca77..e4a7781538d 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_SIGNAL_STRENGTH in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_STRENGTH]) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) From 934591058f349524a1b5b6c10cfc57580a59473d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:12 +0200 Subject: [PATCH 614/759] [nrf52] Test that the mcumgr image is offered beside a UF2 (#19804) --- tests/unit_tests/test_download_types.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 2ccf53f7e3b..0d664b8db66 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -50,3 +50,37 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) assert all( {"title", "description", "file", "download"} <= entry.keys() for entry in types ) + + +def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: + """The files nrf52 offers for a build directory holding *built*.""" + (tmp_path / "zephyr").mkdir() + for name in built: + (tmp_path / name).touch() + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + return [entry["file"] for entry in _download_types("nrf52", storage)] + + +@pytest.mark.parametrize( + ("built", "expected"), + [ + # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. + ( + ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], + ), + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + ( + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ), + (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + ([], ["zephyr/zephyr.hex"]), + ], +) +def test_nrf52_offers_the_mcumgr_image_whenever_it_was_built( + tmp_path: Path, built: list[str], expected: list[str] +) -> None: + """The mcumgr update image is offered beside a UF2 as well as beside a HEX.""" + assert _nrf52_files(tmp_path, *built) == expected From f8d2d1263d9c86fac528e2629400a7c1ccce1c71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:18 +0200 Subject: [PATCH 615/759] [bl0940] Use the sub_* helpers for optional child entities (#19783) --- esphome/components/bl0940/sensor.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 7e6403c3bc9..1cf7e8e6e38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -231,24 +231,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if internal_temperature_config := config.get(CONF_INTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(internal_temperature_config) - cg.add(var.set_internal_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) # enable legacy mode cg.add(var.set_legacy_mode(config.get(CONF_LEGACY_MODE))) From 80cc64a5f22c9e573d719af98f523390e25ed470 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:25 +0200 Subject: [PATCH 616/759] [ina219] Use the sub_* helpers for optional child entities (#19785) --- esphome/components/ina219/sensor.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 97482f81a01..0a81adc785e 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -80,18 +80,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) cg.add(var.set_max_voltage_v(config[CONF_MAX_VOLTAGE])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) From 5f518e70f41935ae7d38fa7d711158e7d607d825 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:40 +0200 Subject: [PATCH 617/759] [ld2410] Use the sub_* helpers for optional child entities (#19774) --- esphome/components/ld2410/binary_sensor.py | 21 +++---- esphome/components/ld2410/button/__init__.py | 18 ++---- esphome/components/ld2410/number/__init__.py | 59 ++++++++++--------- esphome/components/ld2410/select/__init__.py | 62 +++++++++----------- esphome/components/ld2410/sensor.py | 31 ++++------ esphome/components/ld2410/switch/__init__.py | 13 ++-- esphome/components/ld2410/text_sensor.py | 11 ++-- 7 files changed, 90 insertions(+), 125 deletions(-) diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index 2b687335320..f764e89f9b6 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -48,16 +48,11 @@ CONFIG_SCHEMA = { 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) - cg.add(ld2410_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2410_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2410_component.set_still_target_binary_sensor(sens)) - if out_pin_presence_status_config := config.get(CONF_OUT_PIN_PRESENCE_STATUS): - sens = await binary_sensor.new_binary_sensor(out_pin_presence_status_config) - cg.add(ld2410_component.set_out_pin_presence_status_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + await binary_sensors( + CONF_OUT_PIN_PRESENCE_STATUS, hub.set_out_pin_presence_status_binary_sensor + ) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index 59a95583318..3826455986d 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -46,16 +46,8 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_restart_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_query_button(b)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 3500d704a1e..b1891a5935a 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -87,31 +87,32 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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( - timeout_config, min_value=0, max_value=65535, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_timeout_number(n)) - if max_move_distance_gate_config := config.get(CONF_MAX_MOVE_DISTANCE_GATE): - n = await number.new_number( - max_move_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_move_distance_gate_number(n)) - if max_still_distance_gate_config := config.get(CONF_MAX_STILL_DISTANCE_GATE): - n = await number.new_number( - max_still_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_still_distance_gate_number(n)) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_threshold_number(n)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_TIMEOUT, hub.set_timeout_number, min_value=0, max_value=65535, step=1 + ) + await numbers( + CONF_MAX_MOVE_DISTANCE_GATE, + hub.set_max_move_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_MAX_STILL_DISTANCE_GATE, + hub.set_max_still_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) for x in range(9): if gate_conf := config.get(f"g{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] @@ -119,13 +120,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_move_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_still_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index e89e3d59976..936ad83d39d 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -50,36 +50,32 @@ CONFIG_SCHEMA = { 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( - distance_resolution_config, options=["0.2m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_distance_resolution_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_out_pin_level_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_function_select(s)) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_baud_rate_select(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.75m"], + ) + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index ca42b3a1d30..5b523d09442 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -157,30 +157,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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) - cg.add(ld2410_component.set_moving_target_distance_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(ld2410_component.set_still_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(ld2410_component.set_moving_target_energy_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(ld2410_component.set_still_target_energy_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(ld2410_component.set_light_sensor(sens)) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(ld2410_component.set_detection_distance_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) for x in range(9): if gate_conf := config.get(f"g{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(ld2410_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(ld2410_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 6d8053ddd60..646890d43f5 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_engineering_mode_switch(s)) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_bluetooth_switch(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index 25c61a4825f..8a4e5186ef4 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { 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) - cg.add(ld2410_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2410_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) From d0e1e026d86a4422c7f9c97da8e8ad8794a71222 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:43 +0200 Subject: [PATCH 618/759] [seeed_mr24hpc1] Use the sub_* helpers for optional child entities (#19798) --- .../seeed_mr24hpc1/binary_sensor.py | 7 +- .../seeed_mr24hpc1/button/__init__.py | 13 +- .../seeed_mr24hpc1/number/__init__.py | 107 +++++++--------- .../seeed_mr24hpc1/select/__init__.py | 115 ++++++++---------- esphome/components/seeed_mr24hpc1/sensor.py | 39 +++--- .../seeed_mr24hpc1/switch/__init__.py | 10 +- .../components/seeed_mr24hpc1/text_sensor.py | 35 ++---- 7 files changed, 134 insertions(+), 192 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 121eb2b4b36..98f3995ed98 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -15,7 +15,6 @@ CONFIG_SCHEMA = { 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) - cg.add(mr24hpc1_component.set_has_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 3386118bcff..58c62652b09 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -33,12 +33,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_restart_button(b)) - if custom_set_end_config := config.get(CONF_CUSTOM_SET_END): - b = await button.new_button(custom_set_end_config) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_custom_set_end_button(b)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_CUSTOM_SET_END, hub.set_custom_set_end_button) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index d01618b0e60..0dc6d07a96d 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -65,67 +65,46 @@ CONFIG_SCHEMA = cv.Schema( 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( - sensitivity_config, - min_value=0, - max_value=3, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_sensitivity_number(n)) - if custom_mode_config := config.get(CONF_CUSTOM_MODE): - n = await number.new_number( - custom_mode_config, - min_value=0, - max_value=4, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_mode_number(n)) - if existence_threshold_config := config.get(CONF_EXISTENCE_THRESHOLD): - n = await number.new_number( - existence_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_existence_threshold_number(n)) - if motion_threshold_config := config.get(CONF_MOTION_THRESHOLD): - n = await number.new_number( - motion_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_threshold_number(n)) - if motion_trigger_config := config.get(CONF_MOTION_TRIGGER): - n = await number.new_number( - motion_trigger_config, - min_value=0, - max_value=150, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_trigger_number(n)) - if motion_to_rest_config := config.get(CONF_MOTION_TO_REST): - n = await number.new_number( - motion_to_rest_config, - min_value=0, - max_value=3000, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_to_rest_number(n)) - if custom_unman_time_config := config.get(CONF_CUSTOM_UNMAN_TIME): - n = await number.new_number( - custom_unman_time_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_unman_time_number(n)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_SENSITIVITY, hub.set_sensitivity_number, min_value=0, max_value=3, step=1 + ) + await numbers( + CONF_CUSTOM_MODE, hub.set_custom_mode_number, min_value=0, max_value=4, step=1 + ) + await numbers( + CONF_EXISTENCE_THRESHOLD, + hub.set_existence_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_THRESHOLD, + hub.set_motion_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_TRIGGER, + hub.set_motion_trigger_number, + min_value=0, + max_value=150, + step=1, + ) + await numbers( + CONF_MOTION_TO_REST, + hub.set_motion_to_rest_number, + min_value=0, + max_value=3000, + step=1, + ) + await numbers( + CONF_CUSTOM_UNMAN_TIME, + hub.set_custom_unman_time_number, + min_value=0, + max_value=3600, + step=1, + ) diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 9d46dee6f69..208040e9e05 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -40,64 +40,57 @@ CONFIG_SCHEMA = { 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( - scenemode_config, - options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_scene_mode_select(s)) - if unmantime_config := config.get(CONF_UNMAN_TIME): - s = await select.new_select( - unmantime_config, - options=[ - "None", - "10s", - "30s", - "1min", - "2min", - "5min", - "10min", - "30min", - "60min", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_unman_time_select(s)) - if existence_boundary_config := config.get(CONF_EXISTENCE_BOUNDARY): - s = await select.new_select( - existence_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_existence_boundary_select(s)) - if motion_boundary_config := config.get(CONF_MOTION_BOUNDARY): - s = await select.new_select( - motion_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_motion_boundary_select(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_SCENE_MODE, + hub.set_scene_mode_select, + options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], + ) + await selects( + CONF_UNMAN_TIME, + hub.set_unman_time_select, + options=[ + "None", + "10s", + "30s", + "1min", + "2min", + "5min", + "10min", + "30min", + "60min", + ], + ) + await selects( + CONF_EXISTENCE_BOUNDARY, + hub.set_existence_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) + await selects( + CONF_MOTION_BOUNDARY, + hub.set_motion_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index 36ee2c0087e..e2e45656ce8 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -62,27 +62,18 @@ CONFIG_SCHEMA = cv.Schema( 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 - ): - sens = await sensor.new_sensor(custompresenceofdetection_config) - cg.add(mr24hpc1_component.set_custom_presence_of_detection_sensor(sens)) - if movementsigns_config := config.get(CONF_MOVEMENT_SIGNS): - sens = await sensor.new_sensor(movementsigns_config) - cg.add(mr24hpc1_component.set_movement_signs_sensor(sens)) - if custommotiondistance_config := config.get(CONF_CUSTOM_MOTION_DISTANCE): - sens = await sensor.new_sensor(custommotiondistance_config) - cg.add(mr24hpc1_component.set_custom_motion_distance_sensor(sens)) - if customspatialstaticvalue_config := config.get(CONF_CUSTOM_SPATIAL_STATIC_VALUE): - sens = await sensor.new_sensor(customspatialstaticvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_static_value_sensor(sens)) - if customspatialmotionvalue_config := config.get(CONF_CUSTOM_SPATIAL_MOTION_VALUE): - sens = await sensor.new_sensor(customspatialmotionvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_motion_value_sensor(sens)) - if custommotionspeed_config := config.get(CONF_CUSTOM_MOTION_SPEED): - sens = await sensor.new_sensor(custommotionspeed_config) - cg.add(mr24hpc1_component.set_custom_motion_speed_sensor(sens)) - if custommodenum_config := config.get(CONF_CUSTOM_MODE_NUM): - sens = await sensor.new_sensor(custommodenum_config) - cg.add(mr24hpc1_component.set_custom_mode_num_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + sensors = sensor.sub_sensors(config) + await sensors( + CONF_CUSTOM_PRESENCE_OF_DETECTION, hub.set_custom_presence_of_detection_sensor + ) + await sensors(CONF_MOVEMENT_SIGNS, hub.set_movement_signs_sensor) + await sensors(CONF_CUSTOM_MOTION_DISTANCE, hub.set_custom_motion_distance_sensor) + await sensors( + CONF_CUSTOM_SPATIAL_STATIC_VALUE, hub.set_custom_spatial_static_value_sensor + ) + await sensors( + CONF_CUSTOM_SPATIAL_MOTION_VALUE, hub.set_custom_spatial_motion_value_sensor + ) + await sensors(CONF_CUSTOM_MOTION_SPEED, hub.set_custom_motion_speed_sensor) + await sensors(CONF_CUSTOM_MODE_NUM, hub.set_custom_mode_num_sensor) diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index f9588d783e3..76e8a2089bc 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -24,8 +24,8 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_underlying_open_function_switch(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_UNDERLYING_OPEN_FUNCTION, hub.set_underlying_open_function_switch + ) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index 8f284cb20ab..2f926b1eda9 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -49,28 +49,13 @@ CONFIG_SCHEMA = { 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) - cg.add(mr24hpc1_component.set_heartbeat_state_text_sensor(sens)) - if productmodel_config := config.get(CONF_PRODUCT_MODEL): - sens = await text_sensor.new_text_sensor(productmodel_config) - cg.add(mr24hpc1_component.set_product_model_text_sensor(sens)) - if productid_config := config.get(CONF_PRODUCT_ID): - sens = await text_sensor.new_text_sensor(productid_config) - cg.add(mr24hpc1_component.set_product_id_text_sensor(sens)) - if hardwaremodel_config := config.get(CONF_HARDWARE_MODEL): - sens = await text_sensor.new_text_sensor(hardwaremodel_config) - cg.add(mr24hpc1_component.set_hardware_model_text_sensor(sens)) - if firwareversion_config := config.get(CONF_HARDWARE_VERSION): - sens = await text_sensor.new_text_sensor(firwareversion_config) - cg.add(mr24hpc1_component.set_firware_version_text_sensor(sens)) - if keepaway_config := config.get(CONF_KEEP_AWAY): - sens = await text_sensor.new_text_sensor(keepaway_config) - cg.add(mr24hpc1_component.set_keep_away_text_sensor(sens)) - if motionstatus_config := config.get(CONF_MOTION_STATUS): - sens = await text_sensor.new_text_sensor(motionstatus_config) - cg.add(mr24hpc1_component.set_motion_status_text_sensor(sens)) - if custommodeend_config := config.get(CONF_CUSTOM_MODE_END): - sens = await text_sensor.new_text_sensor(custommodeend_config) - cg.add(mr24hpc1_component.set_custom_mode_end_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_HEART_BEAT, hub.set_heartbeat_state_text_sensor) + await text_sensors(CONF_PRODUCT_MODEL, hub.set_product_model_text_sensor) + await text_sensors(CONF_PRODUCT_ID, hub.set_product_id_text_sensor) + await text_sensors(CONF_HARDWARE_MODEL, hub.set_hardware_model_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_firware_version_text_sensor) + await text_sensors(CONF_KEEP_AWAY, hub.set_keep_away_text_sensor) + await text_sensors(CONF_MOTION_STATUS, hub.set_motion_status_text_sensor) + await text_sensors(CONF_CUSTOM_MODE_END, hub.set_custom_mode_end_text_sensor) From 667290988aac0edd80d2ab0f3ef45af8af745cfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:57:50 +0200 Subject: [PATCH 619/759] [ld2450] Use the sub_* helpers for optional child entities (#19775) --- esphome/components/ld2450/binary_sensor.py | 15 ++---- esphome/components/ld2450/button/__init__.py | 13 ++--- esphome/components/ld2450/number/__init__.py | 29 ++++++----- esphome/components/ld2450/select/__init__.py | 53 +++++++++----------- esphome/components/ld2450/sensor.py | 35 ++++++------- esphome/components/ld2450/switch/__init__.py | 13 ++--- esphome/components/ld2450/text_sensor.py | 13 ++--- 7 files changed, 71 insertions(+), 100 deletions(-) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 779d151fd91..c7c1b6b46af 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -41,13 +41,8 @@ CONFIG_SCHEMA = { 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) - cg.add(ld2450_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2450_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2450_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 42cadd2052d..d23f1bebf8f 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 4f242076d66..237c7c4c231 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -80,16 +80,15 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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( - presence_timeout_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_presence_timeout_number(n)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_presence_timeout_number, + min_value=0, + max_value=3600, + step=1, + ) for zone_num in range(MAX_ZONES): if zone_conf := config.get(f"zone_{zone_num + 1}"): zone_x1_config = zone_conf.get(CONF_X1) @@ -97,27 +96,27 @@ async def to_code(config: ConfigType) -> None: await number.register_number( x1, zone_x1_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x1, config[CONF_LD2450_ID]) + await cg.register_parented(x1, hub) zone_y1_config = zone_conf.get(CONF_Y1) y1 = cg.new_Pvariable(zone_y1_config[CONF_ID], zone_num) await number.register_number( y1, zone_y1_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y1, config[CONF_LD2450_ID]) + await cg.register_parented(y1, hub) zone_x2_config = zone_conf.get(CONF_X2) x2 = cg.new_Pvariable(zone_x2_config[CONF_ID], zone_num) await number.register_number( x2, zone_x2_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x2, config[CONF_LD2450_ID]) + await cg.register_parented(x2, hub) zone_y2_config = zone_conf.get(CONF_Y2) y2 = cg.new_Pvariable(zone_y2_config[CONF_ID], zone_num) await number.register_number( y2, zone_y2_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y2, config[CONF_LD2450_ID]) + await cg.register_parented(y2, hub) - cg.add(ld2450_component.set_zone_numbers(zone_num, x1, y1, x2, y2)) + cg.add(hub.set_zone_numbers(zone_num, x1, y1, x2, y2)) diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index d91b42426ad..779dfc2a35c 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -33,31 +33,28 @@ CONFIG_SCHEMA = { 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( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_baud_rate_select(s)) - if zone_type_config := config.get(CONF_ZONE_TYPE): - s = await select.new_select( - zone_type_config, - options=[ - "Disabled", - "Detection", - "Filter", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_zone_type_select(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_ZONE_TYPE, + hub.set_zone_type_select, + options=[ + "Disabled", + "Detection", + "Filter", + ], + ) diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 40462e202d1..e714d353d1f 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -228,47 +228,40 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) + hub = await cg.get_variable(config[CONF_LD2450_ID]) - if target_count_config := config.get(CONF_TARGET_COUNT): - sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_target_count_sensor(sens)) - - if still_target_count_config := config.get(CONF_STILL_TARGET_COUNT): - sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_still_target_count_sensor(sens)) - - if moving_target_count_config := config.get(CONF_MOVING_TARGET_COUNT): - sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_moving_target_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_STILL_TARGET_COUNT, hub.set_still_target_count_sensor) + await sensors(CONF_MOVING_TARGET_COUNT, hub.set_moving_target_count_sensor) for n in range(MAX_TARGETS): if target_conf := config.get(f"target_{n + 1}"): if x_config := target_conf.get(CONF_X): sens = await sensor.new_sensor(x_config) - cg.add(ld2450_component.set_move_x_sensor(n, sens)) + cg.add(hub.set_move_x_sensor(n, sens)) if y_config := target_conf.get(CONF_Y): sens = await sensor.new_sensor(y_config) - cg.add(ld2450_component.set_move_y_sensor(n, sens)) + cg.add(hub.set_move_y_sensor(n, sens)) if speed_config := target_conf.get(CONF_SPEED): sens = await sensor.new_sensor(speed_config) - cg.add(ld2450_component.set_move_speed_sensor(n, sens)) + cg.add(hub.set_move_speed_sensor(n, sens)) if angle_config := target_conf.get(CONF_ANGLE): sens = await sensor.new_sensor(angle_config) - cg.add(ld2450_component.set_move_angle_sensor(n, sens)) + cg.add(hub.set_move_angle_sensor(n, sens)) if distance_config := target_conf.get(CONF_DISTANCE): sens = await sensor.new_sensor(distance_config) - cg.add(ld2450_component.set_move_distance_sensor(n, sens)) + cg.add(hub.set_move_distance_sensor(n, sens)) if resolution_config := target_conf.get(CONF_RESOLUTION): sens = await sensor.new_sensor(resolution_config) - cg.add(ld2450_component.set_move_resolution_sensor(n, sens)) + cg.add(hub.set_move_resolution_sensor(n, sens)) for n in range(MAX_ZONES): if zone_config := config.get(f"zone_{n + 1}"): if target_count_config := zone_config.get(CONF_TARGET_COUNT): sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_zone_target_count_sensor(n, sens)) + cg.add(hub.set_zone_target_count_sensor(n, sens)) if still_target_count_config := zone_config.get(CONF_STILL_TARGET_COUNT): sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_zone_still_target_count_sensor(n, sens)) + cg.add(hub.set_zone_still_target_count_sensor(n, sens)) if moving_target_count_config := zone_config.get(CONF_MOVING_TARGET_COUNT): sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_zone_moving_target_count_sensor(n, sens)) + cg.add(hub.set_zone_moving_target_count_sensor(n, sens)) diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 084f79ee1bc..bb920f6c76e 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { 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) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_bluetooth_switch(s)) - if multi_target_config := config.get(CONF_MULTI_TARGET): - s = await switch.new_switch(multi_target_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_multi_target_switch(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_MULTI_TARGET, hub.set_multi_target_switch) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index a8b978ef488..3eaf6c7fcad 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -51,16 +51,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( 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) - cg.add(ld2450_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2450_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) for n in range(MAX_TARGETS): if (direction_conf := config.get(f"target_{n + 1}")) and ( direction_config := direction_conf.get(CONF_DIRECTION) ): sens = await text_sensor.new_text_sensor(direction_config) - cg.add(ld2450_component.set_direction_text_sensor(n, sens)) + cg.add(hub.set_direction_text_sensor(n, sens)) From f728b8221c9fadcc706e3f634a84ad3165e86501 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 00:58:06 +0200 Subject: [PATCH 620/759] [ina226] Use the sub_* helpers for optional child entities (#19784) --- esphome/components/ina226/sensor.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 4fd98fbcd40..af1793450c9 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -133,18 +133,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_adc_avg_samples(config[CONF_ADC_AVERAGING])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) From 023c81c5b4e64c59544446c9118fe04354bee9db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:09:17 +0200 Subject: [PATCH 621/759] [vbus] Use the sub_* helpers for optional child entities (#19755) --- .../components/vbus/binary_sensor/__init__.py | 153 +++------ esphome/components/vbus/sensor/__init__.py | 311 +++++------------- 2 files changed, 120 insertions(+), 344 deletions(-) diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 5c09a025f89..d3b26d64d8b 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -260,154 +260,81 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + binary_sensors = binary_sensor.sub_binary_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_RELAY1 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY1]) - cg.add(var.set_relay1_bsensor(sens)) - if CONF_RELAY2 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY2]) - cg.add(var.set_relay2_bsensor(sens)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_COLLECTOR_MAX in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MAX]) - cg.add(var.set_collector_max_bsensor(sens)) - if CONF_COLLECTOR_MIN in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MIN]) - cg.add(var.set_collector_min_bsensor(sens)) - if CONF_COLLECTOR_FROST in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_FROST]) - cg.add(var.set_collector_frost_bsensor(sens)) - if CONF_TUBE_COLLECTOR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_TUBE_COLLECTOR]) - cg.add(var.set_tube_collector_bsensor(sens)) - if CONF_RECOOLING in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RECOOLING]) - cg.add(var.set_recooling_bsensor(sens)) - if CONF_HQM in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_HQM]) - cg.add(var.set_hqm_bsensor(sens)) + await binary_sensors(CONF_RELAY1, var.set_relay1_bsensor) + await binary_sensors(CONF_RELAY2, var.set_relay2_bsensor) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors(CONF_COLLECTOR_MAX, var.set_collector_max_bsensor) + await binary_sensors(CONF_COLLECTOR_MIN, var.set_collector_min_bsensor) + await binary_sensors(CONF_COLLECTOR_FROST, var.set_collector_frost_bsensor) + await binary_sensors(CONF_TUBE_COLLECTOR, var.set_tube_collector_bsensor) + await binary_sensors(CONF_RECOOLING, var.set_recooling_bsensor) + await binary_sensors(CONF_HQM, var.set_hqm_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_FROST_PROTECTION_ACTIVE in config: - sens = await binary_sensor.new_binary_sensor( - config[CONF_FROST_PROTECTION_ACTIVE] - ) - cg.add(var.set_frost_protection_active_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors( + CONF_FROST_PROTECTION_ACTIVE, var.set_frost_protection_active_bsensor + ) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index e8a6ea7bfaa..84e78900900 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -654,269 +654,118 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED]) - cg.add(var.set_pump_speed_sensor(sens)) - if CONF_OPERATING_HOURS in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS]) - cg.add(var.set_operating_hours_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) + await sensors(CONF_OPERATING_HOURS, var.set_operating_hours_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: @@ -925,7 +774,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_source(config[CONF_SOURCE])) if CONF_DEST in config: cg.add(var.set_dest(config[CONF_DEST])) - sensors = [] + custom_sensors = [] for conf in config[CONF_SENSORS]: sens = await sensor.new_sensor(conf) lambda_ = await cg.process_lambda( @@ -934,8 +783,8 @@ async def to_code(config: ConfigType) -> None: return_type=cg.float_, ) cg.add(sens.set_message_parser(lambda_)) - sensors.append(sens) - cg.add(var.set_sensors(sensors)) + custom_sensors.append(sens) + cg.add(var.set_sensors(custom_sensors)) vbus = await cg.get_variable(config[CONF_VBUS_ID]) cg.add(vbus.register_listener(var)) From ded631e2b3006064468c23d70eb54d4a1d15ec14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:09:33 +0200 Subject: [PATCH 622/759] [cse7766] Use the sub_* helpers for optional child entities (#19782) --- esphome/components/cse7766/sensor.py | 29 ++++++++-------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 9bed0f3f59e..86b6965e3a2 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -98,24 +98,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if apparent_power_config := config.get(CONF_APPARENT_POWER): - sens = await sensor.new_sensor(apparent_power_config) - cg.add(var.set_apparent_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) From 1fc9a3aa7dd7b62b9828c1736dfec37f5abaab3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:01 +0200 Subject: [PATCH 623/759] [pmsx003] Use the sub_* helpers for optional child entities (#19756) --- esphome/components/pmsx003/sensor.py | 75 ++++++---------------------- 1 file changed, 16 insertions(+), 59 deletions(-) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index dc853802032..5eac5415ed3 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -323,64 +323,21 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_type(config[CONF_TYPE])) - if CONF_PM_1_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0_STD]) - cg.add(var.set_pm_1_0_std_sensor(sens)) - - if CONF_PM_2_5_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5_STD]) - cg.add(var.set_pm_2_5_std_sensor(sens)) - - if CONF_PM_10_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0_STD]) - cg.add(var.set_pm_10_0_std_sensor(sens)) - - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PM_0_3UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_3UM]) - cg.add(var.set_pm_particles_03um_sensor(sens)) - - if CONF_PM_0_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_5UM]) - cg.add(var.set_pm_particles_05um_sensor(sens)) - - if CONF_PM_1_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0UM]) - cg.add(var.set_pm_particles_10um_sensor(sens)) - - if CONF_PM_2_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5UM]) - cg.add(var.set_pm_particles_25um_sensor(sens)) - - if CONF_PM_5_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_5_0UM]) - cg.add(var.set_pm_particles_50um_sensor(sens)) - - if CONF_PM_10_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0UM]) - cg.add(var.set_pm_particles_100um_sensor(sens)) - - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_particles_03um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_particles_05um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_particles_10um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_particles_25um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_particles_50um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_particles_100um_sensor) + await sensors(CONF_FORMALDEHYDE, var.set_formaldehyde_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) From fa8f8593afc0724ed772d80fb68b56b89c5d2766 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:07 +0200 Subject: [PATCH 624/759] [sy6970] Use the sub_* helpers for optional child entities (#19757) --- .../sy6970/binary_sensor/__init__.py | 17 ++++-------- esphome/components/sy6970/sensor/__init__.py | 27 +++++-------------- .../components/sy6970/text_sensor/__init__.py | 17 ++++-------- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index c95850aadc1..a46d7344b68 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -42,16 +42,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): - sens = await binary_sensor.new_binary_sensor(vbus_connected_config) - cg.add(parent.add_listener(sens)) - - if charging_config := config.get(CONF_CHARGING): - sens = await binary_sensor.new_binary_sensor(charging_config) - cg.add(parent.add_listener(sens)) - - if charge_done_config := config.get(CONF_CHARGE_DONE): - sens = await binary_sensor.new_binary_sensor(charge_done_config) - cg.add(parent.add_listener(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_VBUS_CONNECTED, hub.add_listener) + await binary_sensors(CONF_CHARGING, hub.add_listener) + await binary_sensors(CONF_CHARGE_DONE, hub.add_listener) diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index 8f8090b6ee8..16db2aa96b3 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -73,24 +73,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): - sens = await sensor.new_sensor(vbus_voltage_config) - cg.add(parent.add_listener(sens)) - - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(parent.add_listener(sens)) - - if system_voltage_config := config.get(CONF_SYSTEM_VOLTAGE): - sens = await sensor.new_sensor(system_voltage_config) - cg.add(parent.add_listener(sens)) - - if charge_current_config := config.get(CONF_CHARGE_CURRENT): - sens = await sensor.new_sensor(charge_current_config) - cg.add(parent.add_listener(sens)) - - if precharge_current_config := config.get(CONF_PRECHARGE_CURRENT): - sens = await sensor.new_sensor(precharge_current_config) - cg.add(parent.add_listener(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VBUS_VOLTAGE, hub.add_listener) + await sensors(CONF_BATTERY_VOLTAGE, hub.add_listener) + await sensors(CONF_SYSTEM_VOLTAGE, hub.add_listener) + await sensors(CONF_CHARGE_CURRENT, hub.add_listener) + await sensors(CONF_PRECHARGE_CURRENT, hub.add_listener) diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 03a55393b9f..db756960e1b 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -38,16 +38,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if bus_status_config := config.get(CONF_BUS_STATUS): - sens = await text_sensor.new_text_sensor(bus_status_config) - cg.add(parent.add_listener(sens)) - - if charge_status_config := config.get(CONF_CHARGE_STATUS): - sens = await text_sensor.new_text_sensor(charge_status_config) - cg.add(parent.add_listener(sens)) - - if ntc_status_config := config.get(CONF_NTC_STATUS): - sens = await text_sensor.new_text_sensor(ntc_status_config) - cg.add(parent.add_listener(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_BUS_STATUS, hub.add_listener) + await text_sensors(CONF_CHARGE_STATUS, hub.add_listener) + await text_sensors(CONF_NTC_STATUS, hub.add_listener) From 6d9614f4acabb8966fe675bea8e815b313bfdaeb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:19 +0200 Subject: [PATCH 625/759] [ruuvitag] Use the sub_* helpers for optional child entities (#19758) --- esphome/components/ruuvitag/sensor.py | 45 +++++++-------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index 1ea9c457f3e..abbda5872b2 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -129,36 +129,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_PRESSURE in config: - sens = await sensor.new_sensor(config[CONF_PRESSURE]) - cg.add(var.set_pressure(sens)) - if CONF_ACCELERATION in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION]) - cg.add(var.set_acceleration(sens)) - if CONF_ACCELERATION_X in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_X]) - cg.add(var.set_acceleration_x(sens)) - if CONF_ACCELERATION_Y in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Y]) - cg.add(var.set_acceleration_y(sens)) - if CONF_ACCELERATION_Z in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Z]) - cg.add(var.set_acceleration_z(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_TX_POWER in config: - sens = await sensor.new_sensor(config[CONF_TX_POWER]) - cg.add(var.set_tx_power(sens)) - if CONF_MOVEMENT_COUNTER in config: - sens = await sensor.new_sensor(config[CONF_MOVEMENT_COUNTER]) - cg.add(var.set_movement_counter(sens)) - if CONF_MEASUREMENT_SEQUENCE_NUMBER in config: - sens = await sensor.new_sensor(config[CONF_MEASUREMENT_SEQUENCE_NUMBER]) - cg.add(var.set_measurement_sequence_number(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_ACCELERATION, var.set_acceleration) + await sensors(CONF_ACCELERATION_X, var.set_acceleration_x) + await sensors(CONF_ACCELERATION_Y, var.set_acceleration_y) + await sensors(CONF_ACCELERATION_Z, var.set_acceleration_z) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_TX_POWER, var.set_tx_power) + await sensors(CONF_MOVEMENT_COUNTER, var.set_movement_counter) + await sensors(CONF_MEASUREMENT_SEQUENCE_NUMBER, var.set_measurement_sequence_number) From 58437a539ab6caa0e2a552a6fd02e7bc580e36ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:32 +0200 Subject: [PATCH 626/759] [sps30] Use the sub_* helpers for optional child entities (#19759) --- esphome/components/sps30/sensor.py | 50 +++++++----------------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 0026e38979b..b8a4e080213 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -121,45 +121,17 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_4_0]) - cg.add(var.set_pm_4_0_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PMC_0_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_0_5]) - cg.add(var.set_pmc_0_5_sensor(sens)) - - if CONF_PMC_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_1_0]) - cg.add(var.set_pmc_1_0_sensor(sens)) - - if CONF_PMC_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_2_5]) - cg.add(var.set_pmc_2_5_sensor(sens)) - - if CONF_PMC_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_4_0]) - cg.add(var.set_pmc_4_0_sensor(sens)) - - if CONF_PMC_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_10_0]) - cg.add(var.set_pmc_10_0_sensor(sens)) - - if CONF_PM_SIZE in config: - sens = await sensor.new_sensor(config[CONF_PM_SIZE]) - cg.add(var.set_pm_size_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_4_0, var.set_pm_4_0_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PMC_0_5, var.set_pmc_0_5_sensor) + await sensors(CONF_PMC_1_0, var.set_pmc_1_0_sensor) + await sensors(CONF_PMC_2_5, var.set_pmc_2_5_sensor) + await sensors(CONF_PMC_4_0, var.set_pmc_4_0_sensor) + await sensors(CONF_PMC_10_0, var.set_pmc_10_0_sensor) + await sensors(CONF_PM_SIZE, var.set_pm_size_sensor) if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) From 8e9ceb32b92670879577a8c1d632a98ba7783095 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:45 +0200 Subject: [PATCH 627/759] [ezo_pmp] Use the sub_* helpers for optional child entities (#19760) --- esphome/components/ezo_pmp/binary_sensor.py | 12 +++----- esphome/components/ezo_pmp/sensor.py | 32 ++++++--------------- esphome/components/ezo_pmp/text_sensor.py | 12 +++----- 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/esphome/components/ezo_pmp/binary_sensor.py b/esphome/components/ezo_pmp/binary_sensor.py index a81b6c09f05..fcdb94b62b2 100644 --- a/esphome/components/ezo_pmp/binary_sensor.py +++ b/esphome/components/ezo_pmp/binary_sensor.py @@ -31,12 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_PUMP_STATE in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_PUMP_STATE]) - cg.add(parent.set_is_dosing(sens)) - - if CONF_IS_PAUSED in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_IS_PAUSED]) - cg.add(parent.set_is_paused(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUMP_STATE, hub.set_is_dosing) + await binary_sensors(CONF_IS_PAUSED, hub.set_is_paused) diff --git a/esphome/components/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index ed4efeeabcb..1ff214de78e 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -76,28 +76,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_CURRENT_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_CURRENT_VOLUME_DOSED]) - cg.add(parent.set_current_volume_dosed(sens)) - - if CONF_LAST_VOLUME_REQUESTED in config: - sens = await sensor.new_sensor(config[CONF_LAST_VOLUME_REQUESTED]) - cg.add(parent.set_last_volume_requested(sens)) - - if CONF_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_total_volume_dosed(sens)) - - if CONF_ABSOLUTE_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_ABSOLUTE_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_absolute_total_volume_dosed(sens)) - - if CONF_PUMP_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_PUMP_VOLTAGE]) - cg.add(parent.set_pump_voltage(sens)) - - if CONF_MAX_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_MAX_FLOW_RATE]) - cg.add(parent.set_max_flow_rate(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CURRENT_VOLUME_DOSED, hub.set_current_volume_dosed) + await sensors(CONF_LAST_VOLUME_REQUESTED, hub.set_last_volume_requested) + await sensors(CONF_TOTAL_VOLUME_DOSED, hub.set_total_volume_dosed) + await sensors(CONF_ABSOLUTE_TOTAL_VOLUME_DOSED, hub.set_absolute_total_volume_dosed) + await sensors(CONF_PUMP_VOLTAGE, hub.set_pump_voltage) + await sensors(CONF_MAX_FLOW_RATE, hub.set_max_flow_rate) diff --git a/esphome/components/ezo_pmp/text_sensor.py b/esphome/components/ezo_pmp/text_sensor.py index afae1e5188f..47651d0d1da 100644 --- a/esphome/components/ezo_pmp/text_sensor.py +++ b/esphome/components/ezo_pmp/text_sensor.py @@ -24,12 +24,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_DOSING_MODE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DOSING_MODE]) - cg.add(parent.set_dosing_mode(sens)) - - if CONF_CALIBRATION_STATUS in config: - sens = await text_sensor.new_text_sensor(config[CONF_CALIBRATION_STATUS]) - cg.add(parent.set_calibration_status(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DOSING_MODE, hub.set_dosing_mode) + await text_sensors(CONF_CALIBRATION_STATUS, hub.set_calibration_status) From f5204883c7c7a953a1e52c6b188224d5812dec83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:10:58 +0200 Subject: [PATCH 628/759] [ina2xx_base] Use the sub_* helpers for optional child entities (#19761) --- esphome/components/ina2xx_base/__init__.py | 45 +++++----------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 474283ccce1..b6cf0e38f30 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -225,38 +225,13 @@ async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: cg.add(var.set_adc_time_shunt_voltage(adc_time_config)) cg.add(var.set_adc_time_die_temperature(adc_time_config)) - if conf := config.get(CONF_SHUNT_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if conf := config.get(CONF_BUS_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_bus_voltage_sensor(sens)) - - if conf := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_die_temperature_sensor(sens)) - - if conf := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - - if conf := config.get(CONF_POWER): - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - - if conf := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_wh(sens)) - - if conf := config.get(CONF_ENERGY_JOULES): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_j(sens)) - - if conf := config.get(CONF_CHARGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_ah(sens)) - - if conf := config.get(CONF_CHARGE_COULOMBS): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_c(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_TEMPERATURE, var.set_die_temperature_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor_wh) + await sensors(CONF_ENERGY_JOULES, var.set_energy_sensor_j) + await sensors(CONF_CHARGE, var.set_charge_sensor_ah) + await sensors(CONF_CHARGE_COULOMBS, var.set_charge_sensor_c) From a3065edb2916470180c6052e862204fd58f813b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:11:10 +0200 Subject: [PATCH 629/759] [sun_gtil2] Use the sub_* helpers for optional child entities (#19762) --- esphome/components/sun_gtil2/sensor.py | 25 ++++++--------------- esphome/components/sun_gtil2/text_sensor.py | 9 +++----- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 26435cfa67d..2959f365187 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -76,21 +76,10 @@ CONFIG_SCHEMA = cv.All( 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) - cg.add(hub.set_ac_voltage(sens)) - if dc_voltage_config := config.get(CONF_DC_VOLTAGE): - sens = await sensor.new_sensor(dc_voltage_config) - cg.add(hub.set_dc_voltage(sens)) - if ac_power_config := config.get(CONF_AC_POWER): - sens = await sensor.new_sensor(ac_power_config) - cg.add(hub.set_ac_power(sens)) - if dc_power_config := config.get(CONF_DC_POWER): - sens = await sensor.new_sensor(dc_power_config) - cg.add(hub.set_dc_power(sens)) - if limiter_power_config := config.get(CONF_LIMITER_POWER): - sens = await sensor.new_sensor(limiter_power_config) - cg.add(hub.set_limiter_power(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(hub.set_temperature(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_VOLTAGE, hub.set_ac_voltage) + await sensors(CONF_DC_VOLTAGE, hub.set_dc_voltage) + await sensors(CONF_AC_POWER, hub.set_ac_power) + await sensors(CONF_DC_POWER, hub.set_dc_power) + await sensors(CONF_LIMITER_POWER, hub.set_limiter_power) + await sensors(CONF_TEMPERATURE, hub.set_temperature) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index eae69fb4df2..c0a60cacbf3 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -25,9 +25,6 @@ CONFIG_SCHEMA = cv.All( 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) - cg.add(hub.set_state(sens)) - if serial_number_config := config.get(CONF_SERIAL_NUMBER): - sens = await text_sensor.new_text_sensor(serial_number_config) - cg.add(hub.set_serial_number(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_STATE, hub.set_state) + await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number) From c7ab338364a4c65e7993b1e857b43a4039e3b5ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:11:22 +0200 Subject: [PATCH 630/759] [bl0939] Use the sub_* helpers for optional child entities (#19763) --- esphome/components/bl0939/sensor.py | 33 ++++++++--------------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index ec17ef2c7ec..9f6bd7814e4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -94,27 +94,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_1_config := config.get(CONF_CURRENT_1): - sens = await sensor.new_sensor(current_1_config) - cg.add(var.set_current_sensor_1(sens)) - if current_2_config := config.get(CONF_CURRENT_2): - sens = await sensor.new_sensor(current_2_config) - cg.add(var.set_current_sensor_2(sens)) - if active_power_1_config := config.get(CONF_ACTIVE_POWER_1): - sens = await sensor.new_sensor(active_power_1_config) - cg.add(var.set_power_sensor_1(sens)) - if active_power_2_config := config.get(CONF_ACTIVE_POWER_2): - sens = await sensor.new_sensor(active_power_2_config) - cg.add(var.set_power_sensor_2(sens)) - if energy_1_config := config.get(CONF_ENERGY_1): - sens = await sensor.new_sensor(energy_1_config) - cg.add(var.set_energy_sensor_1(sens)) - if energy_2_config := config.get(CONF_ENERGY_2): - sens = await sensor.new_sensor(energy_2_config) - cg.add(var.set_energy_sensor_2(sens)) - if energy_total_config := config.get(CONF_ENERGY_TOTAL): - sens = await sensor.new_sensor(energy_total_config) - cg.add(var.set_energy_sensor_sum(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT_1, var.set_current_sensor_1) + await sensors(CONF_CURRENT_2, var.set_current_sensor_2) + await sensors(CONF_ACTIVE_POWER_1, var.set_power_sensor_1) + await sensors(CONF_ACTIVE_POWER_2, var.set_power_sensor_2) + await sensors(CONF_ENERGY_1, var.set_energy_sensor_1) + await sensors(CONF_ENERGY_2, var.set_energy_sensor_2) + await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum) From 9bce95b69f584f31aa66a6ab0c26c31e56ccd5ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:11:35 +0200 Subject: [PATCH 631/759] [atm90e26] Use the sub_* helpers for optional child entities (#19764) --- esphome/components/atm90e26/sensor.py | 33 ++++++++------------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 87db214233a..5a6b667535f 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -129,30 +129,15 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await spi.register_spi_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) - if forward_active_energy_config := config.get(CONF_FORWARD_ACTIVE_ENERGY): - sens = await sensor.new_sensor(forward_active_energy_config) - cg.add(var.set_forward_active_energy_sensor(sens)) - if reverse_active_energy_config := config.get(CONF_REVERSE_ACTIVE_ENERGY): - sens = await sensor.new_sensor(reverse_active_energy_config) - cg.add(var.set_reverse_active_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_freq_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FORWARD_ACTIVE_ENERGY, var.set_forward_active_energy_sensor) + await sensors(CONF_REVERSE_ACTIVE_ENERGY, var.set_reverse_active_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_freq_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_meter_constant(config[CONF_METER_CONSTANT])) cg.add(var.set_pl_const(config[CONF_PL_CONST])) From a73ed088294afd185102fb761080a1800bb96513 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:11:47 +0200 Subject: [PATCH 632/759] [havells_solar] Use the sub_* helpers for optional child entities (#19777) --- esphome/components/havells_solar/sensor.py | 77 +++++----------------- 1 file changed, 18 insertions(+), 59 deletions(-) diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index efb7af2c3cc..d2db05381d8 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -229,65 +229,24 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_active_power_sensor(sens)) - - if CONF_REACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_REACTIVE_POWER]) - cg.add(var.set_reactive_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_TOTAL_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_GENERATION_TIME]) - cg.add(var.set_total_generation_time_sensor(sens)) - - if CONF_TODAY_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TODAY_GENERATION_TIME]) - cg.add(var.set_today_generation_time_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) - - if CONF_INVERTER_INNER_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_INNER_TEMP]) - cg.add(var.set_inverter_inner_temp_sensor(sens)) - - if CONF_INVERTER_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_BUS_VOLTAGE]) - cg.add(var.set_inverter_bus_voltage_sensor(sens)) - - if CONF_INSULATION_OF_PV_N_TO_GROUND in config: - sens = await sensor.new_sensor(config[CONF_INSULATION_OF_PV_N_TO_GROUND]) - cg.add(var.set_insulation_pv_n_to_ground_sensor(sens)) - - if CONF_GFCI_VALUE in config: - sens = await sensor.new_sensor(config[CONF_GFCI_VALUE]) - cg.add(var.set_gfci_value_sensor(sens)) - - if CONF_DCI_OF_R in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_R]) - cg.add(var.set_dci_of_r_sensor(sens)) - - if CONF_DCI_OF_S in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_S]) - cg.add(var.set_dci_of_s_sensor(sens)) - - if CONF_DCI_OF_T in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_T]) - cg.add(var.set_dci_of_t_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_TOTAL_GENERATION_TIME, var.set_total_generation_time_sensor) + await sensors(CONF_TODAY_GENERATION_TIME, var.set_today_generation_time_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) + await sensors(CONF_INVERTER_INNER_TEMP, var.set_inverter_inner_temp_sensor) + await sensors(CONF_INVERTER_BUS_VOLTAGE, var.set_inverter_bus_voltage_sensor) + await sensors( + CONF_INSULATION_OF_PV_N_TO_GROUND, var.set_insulation_pv_n_to_ground_sensor + ) + await sensors(CONF_GFCI_VALUE, var.set_gfci_value_sensor) + await sensors(CONF_DCI_OF_R, var.set_dci_of_r_sensor) + await sensors(CONF_DCI_OF_S, var.set_dci_of_s_sensor) + await sensors(CONF_DCI_OF_T, var.set_dci_of_t_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: From 6a217aeb6ec276c4ff3c7652b329ee9c5b8bdb0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:12:01 +0200 Subject: [PATCH 633/759] [sdm_meter] Use the sub_* helpers for optional child entities (#19781) --- esphome/components/sdm_meter/sensor.py | 30 ++++++-------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8d7e981d933..74c56acf120 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -160,29 +160,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_TOTAL_POWER in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) - cg.add(var.set_total_power_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_IMPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_ACTIVE_ENERGY]) - cg.add(var.set_import_active_energy_sensor(sens)) - - if CONF_EXPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_ACTIVE_ENERGY]) - cg.add(var.set_export_active_energy_sensor(sens)) - - if CONF_IMPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_REACTIVE_ENERGY]) - cg.add(var.set_import_reactive_energy_sensor(sens)) - - if CONF_EXPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_REACTIVE_ENERGY]) - cg.add(var.set_export_reactive_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: From ee458dc0d5755370fd6c18f36604b07b5bff0db6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:12:15 +0200 Subject: [PATCH 634/759] [sgp30] Use the sub_* helpers for optional child entities (#19786) --- esphome/components/sgp30/sensor.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 75244abee80..b56df3fb2e1 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -87,21 +87,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_eco2_sensor(sens)) - - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc_sensor(sens)) - - if eco2_baseline_config := config.get(CONF_ECO2_BASELINE): - sens = await sensor.new_sensor(eco2_baseline_config) - cg.add(var.set_eco2_baseline_sensor(sens)) - - if tvoc_baseline_config := config.get(CONF_TVOC_BASELINE): - sens = await sensor.new_sensor(tvoc_baseline_config) - cg.add(var.set_tvoc_baseline_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2_BASELINE, var.set_eco2_baseline_sensor) + await sensors(CONF_TVOC_BASELINE, var.set_tvoc_baseline_sensor) if (store_baseline := config.get(CONF_STORE_BASELINE)) is not None: cg.add(var.set_store_baseline(store_baseline)) From c6c6176e7e140e799df369b48105c19f8692b90c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:12:28 +0200 Subject: [PATCH 635/759] [bthome_mithermometer] Use the sub_* helpers for optional child entities (#19789) --- .../components/bthome_mithermometer/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index f559d0aa9b1..86ff34a3492 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -72,18 +72,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) - if temp_sens := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_sens) - cg.add(var.set_temperature(sens)) - if humi_sens := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humi_sens) - cg.add(var.set_humidity(sens)) - if batl_sens := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(batl_sens) - cg.add(var.set_battery_level(sens)) - if batv_sens := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(batv_sens) - cg.add(var.set_battery_voltage(sens)) - if sgnl_sens := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(sgnl_sens) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) From 1303b29a6eb702380999005fdb4c81c42eba134c Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:12:34 +0300 Subject: [PATCH 636/759] [lvgl] Honor esphome build_flags when generating lv_conf.h (#19743) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 15 +++-- .../lvgl/test_lv_conf_build_flags.py | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_build_flags.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2d2f1d6288b..382e483d0cb 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -169,11 +170,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' diff --git a/tests/component_tests/lvgl/test_lv_conf_build_flags.py b/tests/component_tests/lvgl/test_lv_conf_build_flags.py new file mode 100644 index 00000000000..eb576fa8fac --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_build_flags.py @@ -0,0 +1,59 @@ +"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option +the configuration does not use, so ``lv_conf.h`` must leave alone the options +the user defines in build flags. Otherwise the header, which is included after +the compiler ``-D`` flags, turns the option off again. + +Build flags can come from ``esphome: build_flags:`` or from the deprecated +``esphome: platformio_options: build_flags:``; both must be honoured. +""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lvgl import defines as df, generate_lv_conf_h +from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS +from esphome.core import CORE + + +def _set_esphome_config( + build_flags: list[str] | None = None, + pio_build_flags: list[str] | str | None = None, +) -> None: + pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags} + CORE.config = { + CONF_ESPHOME: { + CONF_PLATFORMIO_OPTIONS: pio_options, + CONF_BUILD_FLAGS: build_flags or [], + } + } + + +def test_unused_define_is_disabled_without_build_flag() -> None: + _set_esphome_config() + assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines() + + +@pytest.mark.parametrize( + "flags", + [ + {"build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"build_flags": ["-D LV_USE_OBSERVER"]}, + {"pio_build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"pio_build_flags": "-DLV_USE_OBSERVER=1"}, + ], + ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"], +) +def test_build_flag_define_is_not_disabled(flags: dict) -> None: + _set_esphome_config(**flags) + assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines() + + +def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None: + _set_esphome_config(build_flags=["-DLV_USE_ARC=1"]) + df.add_define("LV_USE_ARC") + with caplog.at_level(logging.WARNING): + generate_lv_conf_h() + assert "LV_USE_ARC" in caplog.text From a991fe6759904e781e694fcbd508c55a90accfe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:12:48 +0200 Subject: [PATCH 637/759] [xiaomi_hhccjcy01] Use the sub_* helpers for optional child entities (#19790) --- esphome/components/xiaomi_hhccjcy01/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 13e8e509c33..11bbceef5e3 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -81,18 +81,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) - if CONF_CONDUCTIVITY in config: - sens = await sensor.new_sensor(config[CONF_CONDUCTIVITY]) - cg.add(var.set_conductivity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) From f3e7603a2f5bea99fe17cf35d6f1d959ab14a998 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:13:01 +0200 Subject: [PATCH 638/759] [bl0942] Use the sub_* helpers for optional child entities (#19791) --- esphome/components/bl0942/sensor.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 5531fe411ba..397471cf7a9 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -101,21 +101,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(var.set_reset(config[CONF_RESET])) From 9b7f9b694eb873f32a4f6f2e1cfc8616e3d075b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:13:13 +0200 Subject: [PATCH 639/759] [hlw8012] Use the sub_* helpers for optional child entities (#19792) --- esphome/components/hlw8012/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 384477be3df..5099dcc94ca 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -107,18 +107,11 @@ async def to_code(config: ConfigType) -> None: cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) cg.add(var.set_cf1_pin(cf1)) - if CONF_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_VOLTAGE]) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY]) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) cg.add(var.set_initial_mode(INITIAL_MODES[config[CONF_INITIAL_MODE]])) From 2e927c98cacb10397d7a8fc59c24fa9a20198e17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 01:13:25 +0200 Subject: [PATCH 640/759] [airthings_wave_plus] Use the sub_* helpers for optional child entities (#19793) --- .../components/airthings_wave_plus/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 8ea79e644fc..75c1eca594f 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -87,16 +87,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await airthings_wave_base.wave_base_to_code(var, config) - if config_radon := config.get(CONF_RADON): - sens = await sensor.new_sensor(config_radon) - cg.add(var.set_radon(sens)) - if config_radon_long_term := config.get(CONF_RADON_LONG_TERM): - sens = await sensor.new_sensor(config_radon_long_term) - cg.add(var.set_radon_long_term(sens)) - if config_co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(config_co2) - cg.add(var.set_co2(sens)) - if config_illuminance := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(config_illuminance) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADON, var.set_radon) + await sensors(CONF_RADON_LONG_TERM, var.set_radon_long_term) + await sensors(CONF_CO2, var.set_co2) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) cg.add(var.set_device_type(config[CONF_DEVICE_TYPE])) From dd777bcecf1f7d5bcaec073a01c7c9aee3e990bc Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:24:42 -0400 Subject: [PATCH 641/759] [core] Share conversion rate configuration constant (#19800) --- esphome/components/const/__init__.py | 1 + esphome/components/emc2101/__init__.py | 2 +- esphome/components/tmp1075/sensor.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index faa2a376709..97d757c9d46 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_COLUMNS = "columns" +CONF_CONVERSION_RATE = "conversion_rate" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 639847345f2..6cd122fb96e 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType @@ -13,7 +14,6 @@ MULTI_CONF = True CONF_PWM = "pwm" CONF_DIVIDER = "divider" CONF_DAC = "dac" -CONF_CONVERSION_RATE = "conversion_rate" CONF_EMC2101_ID = "emc2101_id" diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index 3cbda4a57a8..ab510db0c53 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -41,7 +42,6 @@ CONF_LIMIT_LOW = "limit_low" CONF_LIMIT_HIGH = "limit_high" CONF_FAULT_COUNT = "fault_count" CONF_POLARITY = "polarity" -CONF_CONVERSION_RATE = "conversion_rate" CONF_FUNCTION = "function" CONFIG_SCHEMA = ( From 04b4e05e8525723d643bfdf60550a759a99965c5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:28:04 +0000 Subject: [PATCH 642/759] Bump bundled esphome-device-builder to 1.17.0 (#19814) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c68cd0f4ba4..408e9e21559 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.16.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.17.0 RUN \ platformio settings set enable_telemetry No \ From 378edf5f448a47119d364a3cfabbbe9bc6cbad95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 02:51:11 +0200 Subject: [PATCH 643/759] [bme280_base] Use the sub_* helpers for optional child entities (#19812) --- esphome/components/bme280_base/__init__.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 287946801eb..4b079def64d 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -90,20 +90,14 @@ 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): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) cg.add(var.set_iir_filter(config[CONF_IIR_FILTER])) From 084c6db721ff9bc09494bbb6a18ee261da8430c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 02:51:23 +0200 Subject: [PATCH 644/759] [airthings_wave_base] Use the sub_* helpers for optional child entities (#19811) --- .../airthings_wave_base/__init__.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index 58fde11a3d2..2cec58934d6 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -85,20 +85,11 @@ async def wave_base_to_code(var: MockObj, config: ConfigType) -> None: await ble_client.register_ble_node(var, config) - if config_humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(config_humidity) - cg.add(var.set_humidity(sens)) - if config_temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(config_temperature) - cg.add(var.set_temperature(sens)) - if config_pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(config_pressure) - cg.add(var.set_pressure(sens)) - if config_tvoc := config.get(CONF_TVOC): - sens = await sensor.new_sensor(config_tvoc) - cg.add(var.set_tvoc(sens)) - if config_battery_voltage := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(config_battery_voltage) - cg.add(var.set_battery_voltage(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) if config_battery_update_interval := config.get(CONF_BATTERY_UPDATE_INTERVAL): cg.add(var.set_battery_update_interval(config_battery_update_interval)) From 561158bb516e52ba3ba369e445fc29e955961645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 02:51:37 +0200 Subject: [PATCH 645/759] [mopeka_pro_check] Use the sub_* helpers for optional child entities (#19810) --- esphome/components/mopeka_pro_check/sensor.py | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 5f7c9445f21..761e5831c11 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -159,21 +159,10 @@ async def to_code(config: ConfigType) -> None: ) is not None: cg.add(var.set_min_signal_quality(minimum_signal_quality)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_LEVEL]) - cg.add(var.set_level(sens)) - if CONF_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_DISTANCE]) - cg.add(var.set_distance(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_SIGNAL_QUALITY in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_QUALITY]) - cg.add(var.set_signal_quality(sens)) - if CONF_IGNORED_READS in config: - sens = await sensor.new_sensor(config[CONF_IGNORED_READS]) - cg.add(var.set_ignored_reads(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_LEVEL, var.set_level) + await sensors(CONF_DISTANCE, var.set_distance) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_QUALITY, var.set_signal_quality) + await sensors(CONF_IGNORED_READS, var.set_ignored_reads) From b8fc2f2d431f76829a97a531a745b56e5426bfff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 02:51:49 +0200 Subject: [PATCH 646/759] [pzem004t] Use the sub_* helpers for optional child entities (#19809) --- esphome/components/pzem004t/sensor.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index bf5e31b48ab..2b19cf84985 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -69,19 +69,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) From 8be570f369b093ab5bac67cbb6d90f17e1c4b6d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 02:52:00 +0200 Subject: [PATCH 647/759] [pzemac] Use the sub_* helpers for optional child entities (#19808) --- esphome/components/pzemac/sensor.py | 31 +++++++---------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index ff4cedc8146..90e0abb14c1 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -103,27 +103,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) - if CONF_FREQUENCY in config: - conf = config[CONF_FREQUENCY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_frequency_sensor(sens)) - if CONF_POWER_FACTOR in config: - conf = config[CONF_POWER_FACTOR] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) From 1183beb1fb505948616ef61bc646e69ed414b80d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:18:19 +0200 Subject: [PATCH 648/759] [ci] Refresh integration test durations (#19817) --- .../integration_test_durations.json | 316 +++++++++--------- 1 file changed, 162 insertions(+), 154 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index ea9885872d5..f9f1a43373c 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,156 +1,164 @@ { - "tests/integration/test_action_concurrent_reentry.py": 33.88, - "tests/integration/test_addressable_light_transition.py": 25.37, - "tests/integration/test_alarm_control_panel_state_transitions.py": 29.76, - "tests/integration/test_api_action_metadata.py": 25.54, - "tests/integration/test_api_action_responses.py": 21.15, - "tests/integration/test_api_action_timeout.py": 37.89, - "tests/integration/test_api_conditional_memory.py": 13.21, - "tests/integration/test_api_custom_services.py": 18.72, - "tests/integration/test_api_get_time_response_timezone.py": 19.55, - "tests/integration/test_api_homeassistant.py": 32.09, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04, - "tests/integration/test_api_list_entities_backpressure.py": 14.96, - "tests/integration/test_api_message_size_batching.py": 22.77, - "tests/integration/test_api_reboot_timeout.py": 20.76, - "tests/integration/test_api_string_lambda.py": 35.07, - "tests/integration/test_api_vv_logging.py": 17.17, - "tests/integration/test_api_zero_psk_provisioning.py": 27.91, - "tests/integration/test_areas_and_devices.py": 23.45, - "tests/integration/test_automation_wait_actions.py": 21.21, - "tests/integration/test_automations.py": 30.73, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97, - "tests/integration/test_binary_sensor_invalidate_state.py": 17.7, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43, - "tests/integration/test_build_info.py": 23.12, - "tests/integration/test_camera_mock.py": 21.23, - "tests/integration/test_climate_control_action.py": 26.55, - "tests/integration/test_climate_custom_modes.py": 24.85, - "tests/integration/test_continuation_actions.py": 14.94, - "tests/integration/test_cover_control_action.py": 25.35, - "tests/integration/test_crc8_helper.py": 19.94, - "tests/integration/test_device_id_in_state.py": 57.04, - "tests/integration/test_duplicate_entities.py": 17.91, - "tests/integration/test_entity_icon.py": 23.81, - "tests/integration/test_fan_turn_on_action.py": 14.88, - "tests/integration/test_fnv1_hash_object_id.py": 21.14, - "tests/integration/test_fnv1a_hash.py": 16.96, - "tests/integration/test_gpio_expander_cache.py": 16.1, - "tests/integration/test_host_logger_thread_safety.py": 17.96, - "tests/integration/test_host_mode_basic.py": 8.17, - "tests/integration/test_host_mode_batch_delay.py": 18.78, - "tests/integration/test_host_mode_climate_basic_state.py": 28.88, - "tests/integration/test_host_mode_climate_control.py": 20.11, - "tests/integration/test_host_mode_empty_string_options.py": 21.1, - "tests/integration/test_host_mode_entity_fields.py": 22.3, - "tests/integration/test_host_mode_fan_preset.py": 18.12, - "tests/integration/test_host_mode_many_entities.py": 47.88, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94, - "tests/integration/test_host_mode_noise_encryption.py": 30.68, - "tests/integration/test_host_mode_reconnect.py": 4.8, - "tests/integration/test_host_mode_sensor.py": 17.36, - "tests/integration/test_host_ota.py": 94.88, - "tests/integration/test_host_preferences.py": 20.49, - "tests/integration/test_host_preferences_suspend_resume.py": 24.17, - "tests/integration/test_improv_serial_uart.py": 22.53, - "tests/integration/test_large_message_batching.py": 17.54, - "tests/integration/test_legacy_area.py": 23.03, - "tests/integration/test_legacy_climate_compat.py": 19.3, - "tests/integration/test_legacy_fan_compat.py": 14.69, - "tests/integration/test_light_automations.py": 21.44, - "tests/integration/test_light_binary_effect_off_phase.py": 29.84, - "tests/integration/test_light_calls.py": 26.72, - "tests/integration/test_light_constant_brightness.py": 29.08, - "tests/integration/test_light_control_action.py": 22.44, - "tests/integration/test_light_dim_relative_action.py": 16.57, - "tests/integration/test_light_effect_zero_brightness.py": 32.2, - "tests/integration/test_light_initial_state.py": 18.42, - "tests/integration/test_light_toggle_action.py": 16.23, - "tests/integration/test_lock_automations.py": 28.17, - "tests/integration/test_logger_buffered_recursion_guard.py": 23.52, - "tests/integration/test_loop_disable_enable.py": 19.4, - "tests/integration/test_loop_interval_decoupling.py": 27.32, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47, - "tests/integration/test_lvgl_headless_render.py": 58.56, - "tests/integration/test_micros_to_millis.py": 21.91, - "tests/integration/test_multi_click_trigger.py": 25.74, - "tests/integration/test_multi_device_preferences.py": 24.1, - "tests/integration/test_noise_encryption_key_protection.py": 22.54, - "tests/integration/test_object_id_api_verification.py": 14.59, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88, - "tests/integration/test_object_id_no_friendly_name.py": 40.23, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62, - "tests/integration/test_online_image_bmp.py": 8.46, - "tests/integration/test_oversized_payloads.py": 41.29, - "tests/integration/test_preference_key_stability.py": 33.97, - "tests/integration/test_runtime_stats.py": 23.77, - "tests/integration/test_safe_mode_loop_runs.py": 7.96, - "tests/integration/test_scheduler_blocking_warning.py": 45.2, - "tests/integration/test_scheduler_bulk_cleanup.py": 16.2, - "tests/integration/test_scheduler_defer_cancel.py": 19.87, - "tests/integration/test_scheduler_defer_cancel_regular.py": 15.93, - "tests/integration/test_scheduler_defer_fifo_simple.py": 14.12, - "tests/integration/test_scheduler_defer_stress.py": 24.55, - "tests/integration/test_scheduler_heap_stress.py": 19.84, - "tests/integration/test_scheduler_internal_id_no_collision.py": 18.42, - "tests/integration/test_scheduler_interval_reschedule.py": 22.37, - "tests/integration/test_scheduler_interval_zero_coerced.py": 3.69, - "tests/integration/test_scheduler_null_name.py": 22.08, - "tests/integration/test_scheduler_numeric_id_test.py": 18.45, - "tests/integration/test_scheduler_pool.py": 16.04, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.32, - "tests/integration/test_scheduler_recursive_timeout.py": 24.06, - "tests/integration/test_scheduler_removed_item_race.py": 24.2, - "tests/integration/test_scheduler_self_keyed.py": 19.48, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9, - "tests/integration/test_scheduler_string_test.py": 18.13, - "tests/integration/test_script_array_params.py": 8.06, - "tests/integration/test_script_delay_params.py": 13.13, - "tests/integration/test_script_queued.py": 17.67, - "tests/integration/test_script_queued_idle_loop.py": 16.33, - "tests/integration/test_script_wait_on_boot.py": 14.01, - "tests/integration/test_sdl_headless_screenshot.py": 12.91, - "tests/integration/test_select_stringref_trigger.py": 17.66, - "tests/integration/test_sensor_filters_delta.py": 25.94, - "tests/integration/test_sensor_filters_ring_buffer.py": 17.25, - "tests/integration/test_sensor_filters_sliding_window.py": 44.9, - "tests/integration/test_sensor_filters_value_list.py": 16.0, - "tests/integration/test_sensor_raw_state.py": 42.47, - "tests/integration/test_sensor_timeout_filter.py": 23.58, - "tests/integration/test_set_internal_at_boot.py": 21.55, - "tests/integration/test_snapshot_display.py": 7.58, - "tests/integration/test_socket_wake_gate_tcp.py": 15.76, - "tests/integration/test_status_flags.py": 37.76, - "tests/integration/test_strftime_to.py": 19.69, - "tests/integration/test_syslog.py": 21.44, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98, - "tests/integration/test_template_climate_basic.py": 20.51, - "tests/integration/test_template_climate_custom_modes.py": 16.94, - "tests/integration/test_template_climate_nonoptimistic.py": 19.28, - "tests/integration/test_template_climate_on_control_ordering.py": 15.47, - "tests/integration/test_template_climate_publish_all_fields.py": 18.9, - "tests/integration/test_template_climate_sensor_push.py": 14.96, - "tests/integration/test_template_climate_set_actions.py": 16.04, - "tests/integration/test_template_climate_two_point_temperature.py": 23.72, - "tests/integration/test_template_text_save.py": 15.49, - "tests/integration/test_text_command.py": 14.21, - "tests/integration/test_text_sensor_raw_state.py": 16.32, - "tests/integration/test_uart_mock_ld2410.py": 44.46, - "tests/integration/test_uart_mock_ld2412.py": 85.51, - "tests/integration/test_uart_mock_ld2420.py": 33.99, - "tests/integration/test_uart_mock_ld2450.py": 25.3, - "tests/integration/test_uart_mock_modbus.py": 522.13, - "tests/integration/test_udp.py": 13.7, - "tests/integration/test_use_address_runtime.py": 33.8, - "tests/integration/test_valve_control_action.py": 13.0, - "tests/integration/test_varint_five_byte_device_id.py": 25.11, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.73, - "tests/integration/test_wait_until_on_boot.py": 10.45, - "tests/integration/test_wait_until_ordering.py": 3.64, - "tests/integration/test_wait_until_reentrant_restart.py": 18.73, - "tests/integration/test_wake_loop_forces_phase_b.py": 22.85, - "tests/integration/test_water_heater_template.py": 31.06 + "tests/integration/test_action_concurrent_reentry.py": 31.66, + "tests/integration/test_addressable_light_transition.py": 40.49, + "tests/integration/test_alarm_control_panel_state_transitions.py": 28.84, + "tests/integration/test_api_action_metadata.py": 20.54, + "tests/integration/test_api_action_responses.py": 35.05, + "tests/integration/test_api_action_timeout.py": 25.85, + "tests/integration/test_api_conditional_memory.py": 33.44, + "tests/integration/test_api_custom_services.py": 21.85, + "tests/integration/test_api_decode_wire_types.py": 38.03, + "tests/integration/test_api_empty_message_roundtrip.py": 20.53, + "tests/integration/test_api_encode_boundaries.py": 28.74, + "tests/integration/test_api_get_time_response_timezone.py": 26.53, + "tests/integration/test_api_homeassistant.py": 38.17, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 21.45, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 20.86, + "tests/integration/test_api_list_entities_backpressure.py": 24.14, + "tests/integration/test_api_message_size_batching.py": 32.91, + "tests/integration/test_api_reboot_timeout.py": 21.49, + "tests/integration/test_api_string_lambda.py": 22.48, + "tests/integration/test_api_vv_logging.py": 25.08, + "tests/integration/test_api_zero_psk_provisioning.py": 40.67, + "tests/integration/test_areas_and_devices.py": 26.02, + "tests/integration/test_automation_wait_actions.py": 23.49, + "tests/integration/test_automations.py": 49.82, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 27.22, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 23.85, + "tests/integration/test_binary_sensor_invalidate_state.py": 23.49, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.73, + "tests/integration/test_build_info.py": 23.7, + "tests/integration/test_camera_mock.py": 16.57, + "tests/integration/test_climate_control_action.py": 25.09, + "tests/integration/test_climate_custom_modes.py": 26.78, + "tests/integration/test_continuation_actions.py": 20.51, + "tests/integration/test_cover_control_action.py": 24.95, + "tests/integration/test_crc8_helper.py": 17.19, + "tests/integration/test_device_id_in_state.py": 58.05, + "tests/integration/test_duplicate_entities.py": 30.23, + "tests/integration/test_entity_icon.py": 30.68, + "tests/integration/test_entity_missing_state.py": 27.81, + "tests/integration/test_fan_turn_on_action.py": 23.22, + "tests/integration/test_fnv1_hash_object_id.py": 25.75, + "tests/integration/test_fnv1a_hash.py": 3.64, + "tests/integration/test_gpio_expander_cache.py": 22.24, + "tests/integration/test_host_logger_thread_safety.py": 20.82, + "tests/integration/test_host_mode_basic.py": 4.96, + "tests/integration/test_host_mode_batch_delay.py": 24.14, + "tests/integration/test_host_mode_climate_basic_state.py": 27.28, + "tests/integration/test_host_mode_climate_control.py": 28.31, + "tests/integration/test_host_mode_empty_string_options.py": 27.5, + "tests/integration/test_host_mode_entity_fields.py": 30.67, + "tests/integration/test_host_mode_fan_preset.py": 24.14, + "tests/integration/test_host_mode_many_entities.py": 51.6, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 31.89, + "tests/integration/test_host_mode_noise_encryption.py": 40.77, + "tests/integration/test_host_mode_reconnect.py": 12.62, + "tests/integration/test_host_mode_sensor.py": 22.41, + "tests/integration/test_host_ota.py": 110.14, + "tests/integration/test_host_preferences.py": 24.78, + "tests/integration/test_host_preferences_suspend_resume.py": 22.95, + "tests/integration/test_improv_serial_uart.py": 18.2, + "tests/integration/test_large_message_batching.py": 39.36, + "tests/integration/test_legacy_area.py": 21.25, + "tests/integration/test_legacy_climate_compat.py": 25.65, + "tests/integration/test_legacy_fan_compat.py": 23.25, + "tests/integration/test_light_automations.py": 27.09, + "tests/integration/test_light_binary_effect_off_phase.py": 26.9, + "tests/integration/test_light_calls.py": 31.49, + "tests/integration/test_light_constant_brightness.py": 31.64, + "tests/integration/test_light_control_action.py": 24.78, + "tests/integration/test_light_dim_relative_action.py": 23.69, + "tests/integration/test_light_effect_zero_brightness.py": 25.32, + "tests/integration/test_light_initial_state.py": 24.22, + "tests/integration/test_light_restore_state.py": 23.08, + "tests/integration/test_light_resume_effect.py": 30.38, + "tests/integration/test_light_toggle_action.py": 24.42, + "tests/integration/test_light_transition_state_publish_interval.py": 71.09, + "tests/integration/test_lock_automations.py": 21.54, + "tests/integration/test_logger_buffered_recursion_guard.py": 12.66, + "tests/integration/test_loop_disable_enable.py": 22.87, + "tests/integration/test_loop_interval_decoupling.py": 36.3, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 25.88, + "tests/integration/test_lvgl_headless_render.py": 95.64, + "tests/integration/test_micros_to_millis.py": 27.1, + "tests/integration/test_multi_click_trigger.py": 23.09, + "tests/integration/test_multi_device_preferences.py": 28.62, + "tests/integration/test_noise_encryption_key_protection.py": 32.04, + "tests/integration/test_object_id_api_verification.py": 36.37, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 22.4, + "tests/integration/test_object_id_no_friendly_name.py": 42.47, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 50.34, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 5.45, + "tests/integration/test_online_image_bmp.py": 43.63, + "tests/integration/test_oversized_payloads.py": 52.45, + "tests/integration/test_preference_key_stability.py": 26.26, + "tests/integration/test_runtime_stats.py": 28.11, + "tests/integration/test_safe_mode_loop_runs.py": 10.73, + "tests/integration/test_scheduler_blocking_warning.py": 53.72, + "tests/integration/test_scheduler_bulk_cleanup.py": 21.74, + "tests/integration/test_scheduler_defer_cancel.py": 24.06, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.48, + "tests/integration/test_scheduler_defer_fifo_simple.py": 23.68, + "tests/integration/test_scheduler_defer_stress.py": 23.57, + "tests/integration/test_scheduler_heap_stress.py": 23.12, + "tests/integration/test_scheduler_internal_id_no_collision.py": 22.64, + "tests/integration/test_scheduler_interval_reschedule.py": 20.29, + "tests/integration/test_scheduler_interval_zero_coerced.py": 20.9, + "tests/integration/test_scheduler_null_name.py": 20.45, + "tests/integration/test_scheduler_numeric_id_test.py": 23.77, + "tests/integration/test_scheduler_pool.py": 24.07, + "tests/integration/test_scheduler_rapid_cancellation.py": 25.39, + "tests/integration/test_scheduler_recursive_timeout.py": 5.31, + "tests/integration/test_scheduler_removed_item_race.py": 21.95, + "tests/integration/test_scheduler_self_keyed.py": 5.81, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.44, + "tests/integration/test_scheduler_string_test.py": 24.38, + "tests/integration/test_script_array_params.py": 16.92, + "tests/integration/test_script_delay_params.py": 14.32, + "tests/integration/test_script_queued.py": 25.32, + "tests/integration/test_script_queued_idle_loop.py": 19.93, + "tests/integration/test_script_wait_on_boot.py": 23.34, + "tests/integration/test_sdl_headless_screenshot.py": 26.0, + "tests/integration/test_select_stringref_trigger.py": 22.38, + "tests/integration/test_sensor_filters_delta.py": 25.72, + "tests/integration/test_sensor_filters_ring_buffer.py": 24.23, + "tests/integration/test_sensor_filters_sliding_window.py": 70.81, + "tests/integration/test_sensor_filters_value_list.py": 22.96, + "tests/integration/test_sensor_raw_state.py": 43.36, + "tests/integration/test_sensor_timeout_filter.py": 22.93, + "tests/integration/test_set_internal_at_boot.py": 23.86, + "tests/integration/test_snapshot_display.py": 26.76, + "tests/integration/test_socket_wake_gate_tcp.py": 21.23, + "tests/integration/test_status_flags.py": 35.38, + "tests/integration/test_strftime_to.py": 22.15, + "tests/integration/test_syslog.py": 28.45, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.28, + "tests/integration/test_template_climate_basic.py": 20.56, + "tests/integration/test_template_climate_custom_modes.py": 25.65, + "tests/integration/test_template_climate_nonoptimistic.py": 19.89, + "tests/integration/test_template_climate_on_control_ordering.py": 21.98, + "tests/integration/test_template_climate_publish_all_fields.py": 25.12, + "tests/integration/test_template_climate_sensor_push.py": 25.02, + "tests/integration/test_template_climate_set_actions.py": 21.45, + "tests/integration/test_template_climate_two_point_temperature.py": 24.62, + "tests/integration/test_template_text_save.py": 24.66, + "tests/integration/test_template_time.py": 18.48, + "tests/integration/test_text_command.py": 23.93, + "tests/integration/test_text_sensor_raw_state.py": 22.68, + "tests/integration/test_uart_mock_ld2410.py": 60.58, + "tests/integration/test_uart_mock_ld2412.py": 79.12, + "tests/integration/test_uart_mock_ld2420.py": 40.95, + "tests/integration/test_uart_mock_ld2450.py": 30.8, + "tests/integration/test_uart_mock_modbus.py": 554.44, + "tests/integration/test_udp.py": 9.61, + "tests/integration/test_use_address_runtime.py": 29.41, + "tests/integration/test_valve_control_action.py": 21.9, + "tests/integration/test_varint_five_byte_device_id.py": 23.6, + "tests/integration/test_wait_until_mid_loop_timing.py": 21.89, + "tests/integration/test_wait_until_on_boot.py": 22.57, + "tests/integration/test_wait_until_ordering.py": 21.27, + "tests/integration/test_wait_until_reentrant_restart.py": 18.84, + "tests/integration/test_wake_loop_forces_phase_b.py": 22.68, + "tests/integration/test_water_heater_template.py": 41.77 } From a000ce90bca4452da9cb47820f34b6ce8f12fd4f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 28 Sep 2026 10:53:45 +0200 Subject: [PATCH 649/759] [ble_nus] Increase BLE MTU for NUS (#19629) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ble_nus/__init__.py | 2 + .../components/zephyr_ble_server/__init__.py | 44 +++++++++++++++- .../components/zephyr_mcumgr/ota/__init__.py | 2 + .../zephyr_ble_server/__init__.py | 0 .../zephyr_ble_server/config/no_request.yaml | 8 +++ .../zephyr_ble_server/config/nus.yaml | 11 ++++ .../zephyr_ble_server/config/nus_mcumgr.yaml | 18 +++++++ .../zephyr_ble_server/test_l2cap_mtu.py | 52 +++++++++++++++++++ 8 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/zephyr_ble_server/__init__.py create mode 100644 tests/component_tests/zephyr_ble_server/config/no_request.yaml create mode 100644 tests/component_tests/zephyr_ble_server/config/nus.yaml create mode 100644 tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml create mode 100644 tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index c0837da4025..55a2f9bd188 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -7,6 +7,7 @@ from esphome.components.uart import ( uart_ns, ) from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_DEBUG, @@ -61,6 +62,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT_NUS", True) + request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU expose_log = config[CONF_TYPE] == CONF_LOGS cg.add(var.set_expose_log(expose_log)) if expose_log: diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index a012e832c7e..2493bd9f959 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from esphome import automation import esphome.codegen as cg from esphome.components.nrf52.framework import ( @@ -7,9 +9,48 @@ from esphome.components.nrf52.framework import ( 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, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +# BLE LE Data Length Extension maximum LL PDU payload (BLE spec) +_DLE_MAX_PDU = 251 + +DOMAIN = "zephyr_ble_server" + + +@dataclass +class _BLEServerData: + requested_l2cap_mtu: int = 0 + mtu_emitted: bool = False + + +def _get_data() -> _BLEServerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _BLEServerData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_ble_mtu() -> None: + data = _get_data() + data.mtu_emitted = True + mtu = data.requested_l2cap_mtu + if mtu > 0: + zephyr_add_prj_conf("BT_L2CAP_TX_MTU", mtu) + zephyr_add_prj_conf("BT_BUF_ACL_TX_SIZE", min(mtu + 4, _DLE_MAX_PDU)) + zephyr_add_prj_conf("BT_BUF_ACL_RX_SIZE", mtu + 4) + + +def request_ble_l2cap_mtu(l2cap_mtu: int) -> None: + """Request a minimum BLE L2CAP MTU. The maximum of all callers wins.""" + data = _get_data() + if data.mtu_emitted: + raise RuntimeError("BLE L2CAP MTU requested after it was written to prj.conf") + if data.requested_l2cap_mtu == 0: + CORE.add_job(_emit_ble_mtu) + data.requested_l2cap_mtu = max(data.requested_l2cap_mtu, l2cap_mtu) + + zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -51,6 +92,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("BT_SETTINGS", True) zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 109d1f7dd69..9fc02d87080 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -14,6 +14,7 @@ from esphome.components.zephyr.const import ( KEY_BOOTLOADER, KEY_SYSBUILD, ) +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_HARDWARE_UART, @@ -144,6 +145,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) + request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: uart = UARTS[transport[CONF_HARDWARE_UART]] uart_name = uart[0] diff --git a/tests/component_tests/zephyr_ble_server/__init__.py b/tests/component_tests/zephyr_ble_server/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_ble_server/config/no_request.yaml b/tests/component_tests/zephyr_ble_server/config/no_request.yaml new file mode 100644 index 00000000000..dcf79033eca --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/no_request.yaml @@ -0,0 +1,8 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +zephyr_ble_server: diff --git a/tests/component_tests/zephyr_ble_server/config/nus.yaml b/tests/component_tests/zephyr_ble_server/config/nus.yaml new file mode 100644 index 00000000000..63129069d26 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus.yaml @@ -0,0 +1,11 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +ble_nus: + type: logs diff --git a/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml new file mode 100644 index 00000000000..d62247dc546 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml @@ -0,0 +1,18 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ble_nus: + type: logs + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py new file mode 100644 index 00000000000..af0c4112b03 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py @@ -0,0 +1,52 @@ +"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF +from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu + +MTU_KEYS = ( + "CONFIG_BT_L2CAP_TX_MTU", + "CONFIG_BT_BUF_ACL_TX_SIZE", + "CONFIG_BT_BUF_ACL_RX_SIZE", +) + + +def _mtu_values() -> tuple[int | None, ...]: + prj_conf = zephyr_data()[KEY_PRJ_CONF][""] + return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS) + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("no_request.yaml", (None, None, None)), + ("nus.yaml", (247, 251, 251)), + # The largest request wins; only the TX buffer is capped at 251 + ("nus_mcumgr.yaml", (498, 251, 502)), + ], +) +def test_l2cap_mtu_prj_conf( + fixture: str, + expected: tuple[int | None, ...], + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + assert _mtu_values() == expected + + +@pytest.mark.asyncio +async def test_request_after_emit_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("no_request.yaml")) + request_ble_l2cap_mtu(247) + await _emit_ble_mtu() + with pytest.raises(RuntimeError, match="after it was written"): + request_ble_l2cap_mtu(498) From bd69b17ef6660a6274bd0fe3354700a7da885e77 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 28 Sep 2026 10:54:20 +0200 Subject: [PATCH 650/759] [mqtt] fix compiler warnings when idf_send_async is used (#19815) --- 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 09eb5f97dce..791e5504965 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -108,7 +108,7 @@ void MQTTBackendESP32::loop() { if ((now - this->last_dropped_log_time_) >= DROP_LOG_INTERVAL_MS) { uint16_t dropped = this->mqtt_queue_.get_and_reset_dropped_count(); if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %u messages (%us)", dropped, DROP_LOG_INTERVAL_MS / 1000); + ESP_LOGW(TAG, "Dropped %u messages (%" PRIu32 "s)", dropped, DROP_LOG_INTERVAL_MS / 1000); } this->last_dropped_log_time_ = now; } From bb7af14012598eb6edd86e7da6f5661678f5af01 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 28 Sep 2026 03:58:57 -0500 Subject: [PATCH 651/759] [pn71xx] Remove heap allocation from tag handling and NCI transport (#19738) Co-authored-by: Claude Fable 5.1 --- esphome/components/nfc/nci_message.cpp | 152 ++++++----- esphome/components/nfc/nci_message.h | 48 +++- esphome/components/pn7150_i2c/pn7150_i2c.cpp | 14 +- esphome/components/pn7160/pn7160.cpp | 3 +- esphome/components/pn7160_i2c/pn7160_i2c.cpp | 14 +- esphome/components/pn7160_spi/pn7160_spi.cpp | 8 +- esphome/components/pn71xx/__init__.py | 8 + esphome/components/pn71xx/pn71xx.cpp | 240 +++++++++--------- esphome/components/pn71xx/pn71xx.h | 52 +++- .../pn71xx/pn71xx_mifare_classic.cpp | 86 +++---- .../pn71xx/pn71xx_mifare_ultralight.cpp | 64 ++--- esphome/core/defines.h | 2 + tests/components/pn71xx/pn71xx_test.cpp | 73 +++++- 13 files changed, 441 insertions(+), 323 deletions(-) diff --git a/esphome/components/nfc/nci_message.cpp b/esphome/components/nfc/nci_message.cpp index a144b8a86d7..d00c8af5ea7 100644 --- a/esphome/components/nfc/nci_message.cpp +++ b/esphome/components/nfc/nci_message.cpp @@ -2,56 +2,64 @@ #include "nci_message.h" #include "esphome/core/log.h" -#include - namespace esphome::nfc { -static const char *const TAG = "NciMessage"; - -NciMessage::NciMessage(const uint8_t message_type, const std::vector &payload) { +NciMessage::NciMessage(const uint8_t message_type, const std::span payload) { this->set_message(message_type, payload); } +NciMessage::NciMessage(const uint8_t message_type, const std::initializer_list payload) + : NciMessage(message_type, std::span(payload.begin(), payload.size())) {} + NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { + this->reset(); this->set_header(message_type, gid, oid); } NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { + const std::span payload) { this->set_message(message_type, gid, oid, payload); } -NciMessage::NciMessage(const std::vector &raw_packet) { this->nci_message_ = raw_packet; }; +NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, + const std::initializer_list payload) + : NciMessage(message_type, gid, oid, std::span(payload.begin(), payload.size())) {} -std::vector NciMessage::encode() { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; - std::vector message = this->nci_message_; - return message; +NciMessage::NciMessage(const std::span raw_packet) { + this->nci_message_.assign(raw_packet.begin(), raw_packet.end()); } -void NciMessage::reset() { this->nci_message_ = {0, 0, 0}; } - -uint8_t NciMessage::get_message_type() const { - return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK; +std::span NciMessage::encode() { + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; + return this->nci_message_; } -uint8_t NciMessage::get_gid() const { return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK; } +void NciMessage::reset() { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = 0; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = 0; +} -uint8_t NciMessage::get_oid() const { return this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK; } +uint8_t NciMessage::get_message_type() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK; } + +uint8_t NciMessage::get_gid() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK; } + +uint8_t NciMessage::get_oid() const { return this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK; } uint8_t NciMessage::get_payload_size(const bool recompute) { if (!this->nci_message_.empty()) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return 0; } uint8_t NciMessage::get_simple_status_response() const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return STATUS_FAILED; } @@ -63,104 +71,112 @@ uint8_t NciMessage::get_message_byte(const uint8_t offset) const { return 0; } -std::vector &NciMessage::get_message() { return this->nci_message_; } +std::span NciMessage::get_payload() const { + if (this->nci_message_.size() <= NCI_PKT_HEADER_SIZE) { + return {}; + } + return {this->nci_message_.data() + NCI_PKT_HEADER_SIZE, this->nci_message_.size() - NCI_PKT_HEADER_SIZE}; +} -bool NciMessage::has_payload() const { return this->nci_message_.size() > nfc::NCI_PKT_HEADER_SIZE; } +bool NciMessage::has_payload() const { return this->nci_message_.size() > NCI_PKT_HEADER_SIZE; } bool NciMessage::message_type_is(const uint8_t message_type) const { if (!this->nci_message_.empty()) { - return message_type == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK); + return message_type == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK); } return false; } bool NciMessage::message_length_is(const uint8_t message_length, const bool recompute) { - if (this->nci_message_.size() > nfc::NCI_PKT_LENGTH_OFFSET) { + if (this->nci_message_.size() > NCI_PKT_LENGTH_OFFSET) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return message_length == this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return message_length == this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return false; } bool NciMessage::gid_is(const uint8_t gid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_MT_GID_OFFSET) { - return gid == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK); + if (this->nci_message_.size() > NCI_PKT_MT_GID_OFFSET) { + return gid == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK); } return false; } bool NciMessage::oid_is(const uint8_t oid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_OID_OFFSET) { - return oid == (this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK); + if (this->nci_message_.size() > NCI_PKT_OID_OFFSET) { + return oid == (this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK); } return false; } bool NciMessage::simple_status_response_is(const uint8_t response) const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return response == this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return response == this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return false; } void NciMessage::set_header(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = (message_type & NCI_PKT_MT_MASK) | (gid & NCI_PKT_GID_MASK); + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_message(const uint8_t message_type, const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = message_type & nfc::NCI_PKT_MT_MASK; - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = 0; - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); +void NciMessage::set_message(const uint8_t message_type, const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = message_type & NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); + const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->set_header(message_type, gid, oid); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message_type(const uint8_t message_type) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto mt_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_MT_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & nfc::NCI_PKT_MT_MASK); + auto mt_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & NCI_PKT_MT_MASK); } void NciMessage::set_gid(const uint8_t gid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto gid_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_GID_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & nfc::NCI_PKT_GID_MASK); + auto gid_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_GID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & NCI_PKT_GID_MASK); } void NciMessage::set_oid(const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_payload(const std::vector &payload) { - std::vector message(this->nci_message_.begin(), this->nci_message_.begin() + nfc::NCI_PKT_HEADER_SIZE); - - message.insert(message.end(), payload.begin(), payload.end()); - message[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_ = message; +void NciMessage::set_payload(const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } +void NciMessage::append(const std::span data) { + for (const uint8_t byte : data) { + this->nci_message_.push_back(byte); + } +} + +void NciMessage::set_payload_size(const uint8_t size) { this->nci_message_.resize(NCI_PKT_HEADER_SIZE + size); } + } // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_message.h b/esphome/components/nfc/nci_message.h index 8e8b1103363..de8a4cf56ae 100644 --- a/esphome/components/nfc/nci_message.h +++ b/esphome/components/nfc/nci_message.h @@ -3,19 +3,30 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include +#include namespace esphome::nfc { +// An NCI packet is a three-byte header followed by up to 255 payload bytes +static constexpr size_t NCI_PKT_MAX_PAYLOAD_SIZE = 255; +static constexpr size_t NCI_PKT_MAX_SIZE = 3 + NCI_PKT_MAX_PAYLOAD_SIZE; + +/// One NCI packet, held in a fixed buffer so building and reading messages never allocates class NciMessage { public: - NciMessage() {} - NciMessage(uint8_t message_type, const std::vector &payload); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); - NciMessage(const std::vector &raw_packet); + using Buffer = StaticVector; - std::vector encode(); + NciMessage() { this->reset(); } + NciMessage(uint8_t message_type, std::span payload); + NciMessage(uint8_t message_type, std::initializer_list payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::initializer_list payload); + explicit NciMessage(std::span raw_packet); + + /// Stamps the payload length into the header and returns the packet ready to send + std::span encode(); void reset(); uint8_t get_message_type() const; @@ -24,7 +35,10 @@ class NciMessage { uint8_t get_payload_size(bool recompute = false); uint8_t get_simple_status_response() const; uint8_t get_message_byte(uint8_t offset) const; - std::vector &get_message(); + Buffer &get_message() { return this->nci_message_; } + const Buffer &get_message() const { return this->nci_message_; } + /// The payload bytes that follow the header + std::span get_payload() const; bool has_payload() const; bool message_type_is(uint8_t message_type) const; @@ -34,15 +48,25 @@ class NciMessage { bool simple_status_response_is(uint8_t response) const; void set_header(uint8_t message_type, uint8_t gid, uint8_t oid); - void set_message(uint8_t message_type, const std::vector &payload); - void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); + void set_message(uint8_t message_type, std::span payload); + void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); void set_message_type(uint8_t message_type); void set_gid(uint8_t gid); void set_oid(uint8_t oid); - void set_payload(const std::vector &payload); + void set_payload(std::span payload); + void set_payload(std::initializer_list payload) { + this->set_payload(std::span(payload.begin(), payload.size())); + } + /// Appends bytes to the payload; bytes that do not fit are dropped + void append(std::span data); + void append(std::initializer_list data) { + this->append(std::span(data.begin(), data.size())); + } + /// Sets the packet size to the header plus `size` payload bytes, for a bus driver filling the buffer directly + void set_payload_size(uint8_t size); protected: - std::vector nci_message_{0, 0, 0}; // three bytes, MT/PBF/GID, OID, payload length/size + Buffer nci_message_; // MT/PBF/GID, OID, payload length, then the payload }; } // namespace esphome::nfc diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index a4e45b8584a..58d9daa311e 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -12,17 +12,15 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { + return nfc::STATUS_FAILED; } // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short // low pulse may be missed; that means more data is waiting, not that this read failed (UM10936, 3.4). @@ -33,7 +31,7 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7150I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index 6dcb73bee12..185ff0ce097 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -68,9 +68,8 @@ uint8_t PN7160::verify_reset(nfc::NciMessage &rx, const bool reset_config) { uint8_t PN7160::process_init_response(nfc::NciMessage &rx) { // the chip's version information is logged from CORE_RESET_NTF in verify_reset() if (rx.get_message().size() >= 8) { - std::vector features(rx.get_message().begin() + 4, rx.get_message().begin() + 8); char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, features)); + ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, rx.get_payload().subspan(1, 4))); } return rx.get_simple_status_response(); diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index 799a258b983..d559c6c2517 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -12,17 +12,15 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { + return nfc::STATUS_FAILED; } // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.2.4). @@ -33,7 +31,7 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7160I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index 43420f9efdf..85a6c1fb7d0 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -16,14 +16,14 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); this->enable(); this->write_byte(TDD_SPI_READ); // send "transfer direction detector" this->read_array(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE); - uint8_t length = rx.get_payload_size(); + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); this->read_array(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length); } this->disable(); @@ -36,7 +36,7 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7160Spi::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); this->enable(); // send "transfer direction detector"; the NFCC answers 0xFF when it is ready to receive (UM11495, 6.3.3) const uint8_t status = this->transfer_byte(TDD_SPI_WRITE); diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 8cda915febc..a8825747d17 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -117,6 +117,12 @@ _CALLBACK_AUTOMATIONS = ( ) +_request_ontag_trigger_slot = cg.slot_counter("PN71XX_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT" +) + + async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) @@ -135,6 +141,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf @@ -142,6 +149,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG_REMOVED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp index f13e2de7026..3d0b46efb47 100644 --- a/esphome/components/pn71xx/pn71xx.cpp +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -56,7 +56,10 @@ void PN71xx::set_tag_emulation_message(const std::shared_ptr & ESP_LOGE(TAG, "Tag emulation message too long: %zu > %u bytes", encoded.size(), CARD_EMU_T4T_MAX_NDEF_SIZE); return; } - this->card_emulation_ndef_ = std::move(encoded); + this->card_emulation_ndef_.init(encoded.size()); + for (const uint8_t byte : encoded) { + this->card_emulation_ndef_.push_back(byte); + } ESP_LOGD(TAG, "Tag emulation message set"); } @@ -190,8 +193,9 @@ uint8_t PN71xx::set_test_mode(const TestMode test_mode, const std::vectornci_fsm_set_state_(NCIState::NFCC_RESET); result.clear(); } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header + // the payload after the status byte, if the NFCC sent one + const auto payload = rx.get_payload(); + result.assign(payload.begin() + std::min(1, payload.size()), payload.end()); if (!result.empty()) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); @@ -256,9 +260,7 @@ uint8_t PN71xx::send_init_config_() { return nfc::STATUS_FAILED; } - const auto pmu_config = this->pmu_config(); - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(pmu_config.begin(), pmu_config.end())); + tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, this->pmu_config()); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error sending PMU config"); @@ -269,19 +271,16 @@ uint8_t PN71xx::send_init_config_() { } uint8_t PN71xx::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); + std::span core_config = CORE_CONFIG_SOLO; this->core_config_is_solo_ = true; if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); + core_config = CORE_CONFIG_RW_CE; this->core_config_is_solo_ = false; } nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, core_config); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGW(TAG, "Error sending core config"); @@ -313,11 +312,10 @@ uint8_t PN71xx::refresh_core_config_() { } uint8_t PN71xx::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, + {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}); + tx.append(RF_DISCOVER_MAP_CONFIG); if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error sending discover map poll config"); @@ -327,10 +325,9 @@ uint8_t PN71xx::set_discover_map_() { } uint8_t PN71xx::set_listen_mode_routing_() { - const auto routing_config = this->listen_mode_routing_config(); nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(routing_config.begin(), routing_config.end())); + this->listen_mode_routing_config()); if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error setting listen mode routing config"); @@ -340,27 +337,20 @@ uint8_t PN71xx::set_listen_mode_routing_() { } uint8_t PN71xx::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); + std::span rf_discovery_config = RF_DISCOVERY_CONFIG; if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; } - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, + {static_cast(rf_discovery_config.size())}); + for (const uint8_t mode_tech : rf_discovery_config) { + tx.append({mode_tech, 0x01}); // RF Technology and Mode will be executed in every discovery period + } if (this->transceive_(tx, rx) != nfc::STATUS_OK) { switch (rx.get_simple_status_response()) { @@ -416,10 +406,9 @@ void PN71xx::select_endpoint_() { } else if (endpoint.protocol == nfc::PROT_MIFARE) { interface = nfc::INTF_TAGCMD; } - std::vector endpoint_data = {endpoint.id, endpoint.protocol, interface}; - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, + {endpoint.id, endpoint.protocol, interface}); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error selecting endpoint"); @@ -491,31 +480,57 @@ uint8_t PN71xx::write_endpoint_(const uint8_t protocol, nfc::NfcTagUid &uid, return nfc::STATUS_FAILED; } -std::unique_ptr PN71xx::build_tag_(const uint8_t mode_tech, const uint8_t protocol, - const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... - if (data.size() < 3) { - ESP_LOGE(TAG, "NFC-A parameters too short"); - return nullptr; - } - uint8_t uid_length = data[2]; - if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || data.size() < 3u + uid_length) { - ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - if (protocol == nfc::PROT_MIFARE) { - return make_unique(uid, nfc::MIFARE_CLASSIC); - } - if (protocol == nfc::PROT_T2T) { - return make_unique(uid, nfc::NFC_FORUM_TYPE_2); - } - return make_unique(uid); - } +bool PN71xx::parse_uid_(const uint8_t mode_tech, const std::span rf_tech_params, nfc::NfcTagUid &uid) { + if (mode_tech != (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA)) { + return false; } - return nullptr; + // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... + if (rf_tech_params.size() < 3) { + ESP_LOGE(TAG, "NFC-A parameters too short"); + return false; + } + const uint8_t uid_length = rf_tech_params[2]; + if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || rf_tech_params.size() < 3u + uid_length) { + ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); + return false; + } + uid.assign(rf_tech_params.begin() + 3, rf_tech_params.begin() + 3 + uid_length); + return true; +} + +std::unique_ptr PN71xx::build_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + if (protocol == nfc::PROT_MIFARE) { + return make_unique(uid, nfc::MIFARE_CLASSIC); + } + if (protocol == nfc::PROT_T2T) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + return make_unique(uid); +} + +size_t PN71xx::find_or_add_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + const auto tag_loc = this->find_tag_uid_(uid); + if (tag_loc.has_value()) { + ESP_LOGVV(TAG, "Tag cache updated"); + return tag_loc.value(); + } + if (this->discovered_endpoint_.size() >= MAX_DISCOVERED_ENDPOINTS) { + size_t oldest = 0; + for (size_t i = 1; i < this->discovered_endpoint_.size(); i++) { + if (this->discovered_endpoint_[i].last_seen < this->discovered_endpoint_[oldest].last_seen) { + oldest = i; + } + } + ESP_LOGW(TAG, "Tag cache full; dropping the tag seen longest ago"); + this->erase_tag_(oldest); + } + this->discovered_endpoint_.emplace_next() = DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), + .tag = this->build_tag_(protocol, uid), + .id = 0, + .protocol = protocol, + .trig_called = false}; + ESP_LOGVV(TAG, "Tag added to cache"); + return this->discovered_endpoint_.size() - 1; } optional PN71xx::find_tag_uid_(const nfc::NfcTagUid &uid) { @@ -549,15 +564,23 @@ void PN71xx::purge_old_tags_() { void PN71xx::erase_tag_(const uint8_t tag_index) { if (tag_index < this->discovered_endpoint_.size()) { +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT for (auto *trigger : this->triggers_ontagremoved_) { trigger->process(this->discovered_endpoint_[tag_index].tag); } +#endif for (auto *listener : this->tag_listeners_) { listener->tag_off(*this->discovered_endpoint_[tag_index].tag); } char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); + // keep the remaining entries in order; selecting_endpoint_ indexes into this list + for (size_t i = tag_index; i + 1 < this->discovered_endpoint_.size(); i++) { + this->discovered_endpoint_[i] = std::move(this->discovered_endpoint_[i + 1]); + } + // StaticVector::resize() does not destroy the dropped slot; free its tag now, not when the slot is reused + this->discovered_endpoint_[this->discovered_endpoint_.size() - 1].tag.reset(); + this->discovered_endpoint_.resize(this->discovered_endpoint_.size() - 1); } } @@ -815,31 +838,16 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); return; } - auto incoming_tag = - this->build_tag_(mode_tech, protocol, - std::vector(rx.get_message().begin() + nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS, - rx.get_message().end())); - - if (incoming_tag == nullptr) { + nfc::NfcTagUid uid; + if (!this->parse_uid_(mode_tech, + std::span(rx.get_message()).subspan(nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS), + uid)) { ESP_LOGE(TAG, "Could not build tag"); } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), - .tag = std::move(incoming_tag), - .id = discovery_id, - .protocol = protocol, - .trig_called = false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; + auto &working_endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + working_endpoint.id = discovery_id; + working_endpoint.protocol = protocol; + working_endpoint.last_seen = App.get_loop_component_start_time(); switch (this->next_task_) { case EP_CLEAN: @@ -886,8 +894,8 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi if (this->read_endpoint_data_(working_endpoint.protocol, *working_endpoint.tag) != nfc::STATUS_OK) { ESP_LOGW(TAG, " Unable to read NDEF record(s)"); } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); + const auto &message = working_endpoint.tag->get_ndef_message(); + const auto &records = message->get_records(); ESP_LOGD(TAG, " NDEF record(s):"); for (const auto &record : records) { ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); @@ -895,9 +903,11 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi } else { ESP_LOGW(TAG, " No NDEF records found"); } +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT for (auto *trigger : this->triggers_ontag_) { trigger->process(working_endpoint.tag); } +#endif for (auto *listener : this->tag_listeners_) { listener->tag_on(*working_endpoint.tag); } @@ -924,31 +934,20 @@ void PN71xx::process_rf_discover_oid_(nfc::NciMessage &rx) { ESP_LOGE(TAG, "RF_DISCOVER_NTF too short"); return; } - auto incoming_tag = this->build_tag_( - rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - std::vector(rx.get_message().begin() + nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS, rx.get_message().end())); - - if (incoming_tag == nullptr) { + const uint8_t protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); + nfc::NfcTagUid uid; + if (!this->parse_uid_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), + std::span(rx.get_message()).subspan(nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS), uid)) { ESP_LOGE(TAG, "Could not build tag!"); } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), - .tag = std::move(incoming_tag), - .id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - .protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - .trig_called = false}); - ESP_LOGVV(TAG, "Tag saved"); - } + auto &endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + endpoint.id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); + endpoint.protocol = protocol; + endpoint.last_seen = App.get_loop_component_start_time(); } - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { + const auto &ntf = rx.get_message(); + if (ntf[ntf.size() - 1] != nfc::RF_DISCOVER_NTF_NT_MORE) { this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); ESP_LOGVV(TAG, "Discovered %zu endpoints", this->discovered_endpoint_.size()); } @@ -986,17 +985,12 @@ void PN71xx::process_data_message_(nfc::NciMessage &rx) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - std::vector ndef_response; + CardEmuResponse ndef_response; this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); if (ndef_response.empty()) { return; // no message returned, we cannot respond } - // the CC limits MLe so every response fits in a single data packet (payload length is one byte) - if (ndef_response.size() > UINT8_MAX) { - ESP_LOGE(TAG, "Card emulation response too long: %zu bytes", ndef_response.size()); - return; - } nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, ndef_response); ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); @@ -1005,12 +999,14 @@ void PN71xx::process_data_message_(nfc::NciMessage &rx) { } } -bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, std::vector &ndef_response) { +bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, CardEmuResponse &ndef_response) { const auto &ndef_message = this->card_emulation_ndef_; // the NDEF file is a two-byte big-endian length (NLEN) followed by the message const uint16_t ndef_msg_size = ndef_message.size(); const uint32_t file_size = ndef_msg_size + 2; - if (offset + static_cast(length) > file_size) { + // the reply must also hold the two status bytes; the CC's MLe keeps well-behaved readers below this + if (offset + static_cast(length) > file_size || + length + sizeof(CARD_EMU_T4T_OK) > CardEmuResponse::capacity()) { return false; } for (uint32_t i = offset; i < offset + static_cast(length); i++) { @@ -1029,7 +1025,7 @@ bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length return true; } -void PN71xx::card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response) { +void PN71xx::card_emu_t4t_get_response_(const std::span response, CardEmuResponse &ndef_response) { ndef_response.clear(); if (this->card_emulation_ndef_.empty()) { ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); @@ -1047,6 +1043,11 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st auto apdu_starts_with = [&](const uint8_t *cmd, size_t cmd_size) { return apdu_size >= cmd_size && std::equal(cmd, cmd + cmd_size, apdu_begin); }; + auto append = [&](std::span bytes) { + for (const uint8_t byte : bytes) { + ndef_response.push_back(byte); + } + }; bool ok = false; if (apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT)) || @@ -1071,8 +1072,7 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); if (offset + static_cast(length) <= sizeof(CARD_EMU_T4T_CC)) { - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); + append(std::span(CARD_EMU_T4T_CC).subspan(offset, length)); ok = true; } } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { @@ -1084,18 +1084,18 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st const uint8_t length = apdu_begin[4]; if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED && apdu_size >= 5u + length) { ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - std::vector ndef_msg_written(apdu_begin + 5, apdu_begin + 5 + length); char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(write_buf, ndef_msg_written)); + ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, + nfc::format_bytes_to(write_buf, response.subspan(nfc::NCI_PKT_HEADER_SIZE + 5, length))); ok = true; } } if (ok) { - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); + append(CARD_EMU_T4T_OK); } else { ndef_response.clear(); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_NOK), std::end(CARD_EMU_T4T_NOK)); + append(CARD_EMU_T4T_NOK); this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; } } diff --git a/esphome/components/pn71xx/pn71xx.h b/esphome/components/pn71xx/pn71xx.h index f9cfddc2e92..940d1874fc0 100644 --- a/esphome/components/pn71xx/pn71xx.h +++ b/esphome/components/pn71xx/pn71xx.h @@ -9,6 +9,7 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include #include #include @@ -68,7 +69,8 @@ static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter f 0xF8, // TOTAL_DURATION (low)... 0x02}; // TOTAL_DURATION (high): 760 ms -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes +static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { + // poll modes nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, nfc::INTF_FRAME, // poll mode nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, @@ -131,6 +133,16 @@ enum class TestMode : uint8_t { TEST_GET_REGISTER, }; +/// A card emulation reply; the CC limits reads so every reply fits one NCI data packet +using CardEmuResponse = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// Tags tracked at once. A device with a random UID looks like a new tag on every activation, but each entry +/// expires after tag_ttl, so a handful is enough; when the cache is full the entry seen longest ago is evicted. +static constexpr size_t MAX_DISCOVERED_ENDPOINTS = 8; + struct DiscoveredEndpoint { uint32_t last_seen; std::unique_ptr tag; @@ -162,8 +174,12 @@ class PN71xx : public nfc::Nfcc, public Component { void set_polling_on(); bool polling_enabled() { return this->polling_enabled_; } +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif template void add_on_emulated_tag_scan_callback(F &&callback) { this->on_emulated_tag_scan_callback_.add(std::forward(callback)); @@ -219,7 +235,11 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t format_endpoint_(uint8_t protocol); uint8_t write_endpoint_(uint8_t protocol, nfc::NfcTagUid &uid, std::shared_ptr &message); - std::unique_ptr build_tag_(uint8_t mode_tech, uint8_t protocol, const std::vector &data); + /// Reads the UID from the RF technology parameters of a discovery or activation notification + bool parse_uid_(uint8_t mode_tech, std::span rf_tech_params, nfc::NfcTagUid &uid); + std::unique_ptr build_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); + /// Finds a cached endpoint by UID, or caches a new one, evicting the entry seen longest ago if the cache is full + size_t find_or_add_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); optional find_tag_uid_(const nfc::NfcTagUid &uid); void purge_old_tags_(); void erase_tag_(uint8_t tag_index); @@ -237,8 +257,8 @@ class PN71xx : public nfc::Nfcc, public Component { void process_rf_deactivate_oid_(nfc::NciMessage &rx); void process_data_message_(nfc::NciMessage &rx); - void card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response); - bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, std::vector &ndef_response); + void card_emu_t4t_get_response_(std::span response, CardEmuResponse &ndef_response); + bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, CardEmuResponse &ndef_response); uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool expect_notification = true); @@ -248,7 +268,7 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); + uint8_t read_mifare_classic_block_(uint8_t block_num, std::array &data); uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); uint8_t sect_to_auth_(uint8_t block_num); @@ -258,10 +278,10 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t halt_mifare_classic_tag_(); uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + uint8_t find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index); uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); @@ -275,13 +295,17 @@ class PN71xx : public nfc::Nfcc, public Component { }; // members are ordered by alignment, widest first, to minimize padding - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; + LazyCallbackManager on_emulated_tag_scan_callback_; + LazyCallbackManager on_finished_write_callback_; - std::vector discovered_endpoint_; - std::vector card_emulation_ndef_; // encoded emulation message; empty when none is set - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + StaticVector discovered_endpoint_; + FixedVector card_emulation_ndef_; // encoded emulation message; empty when none is set +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif std::shared_ptr next_task_message_to_write_; GPIOPin *irq_pin_{nullptr}; diff --git a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp index 0bd6148e8a8..c1c927634d4 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include "pn71xx.h" @@ -17,56 +19,59 @@ uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); return nfc::STATUS_FAILED; } - std::vector data; + std::array block_data; - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data) == nfc::STATUS_OK) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return nfc::STATUS_FAILED; } } else { ESP_LOGE(TAG, "Failed to read block %u", current_block); return nfc::STATUS_FAILED; } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return nfc::STATUS_FAILED; + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return nfc::STATUS_FAILED; + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Block authentication failed for %u", current_block); return nfc::STATUS_FAILED; } } - std::vector block_data; if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading block %u", current_block); return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + } + for (const uint8_t byte : block_data) { + buffer.push_back(byte); } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return nfc::STATUS_FAILED; } - - tag.set_ndef_message(make_unique(buffer)); + tag.set_ndef_message(make_unique(std::span(buffer).subspan(message_start_index))); return nfc::STATUS_OK; } -uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { +uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, + std::array &data) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -84,32 +89,35 @@ uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, std::vectorsect_to_auth_(block_num), key_num}); - + uint8_t key_select = key_num; switch (key_num) { case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; + key_select = MFC_AUTHENTICATE_PARAM_KS_A; break; case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; + key_select = MFC_AUTHENTICATE_PARAM_KS_B; break; default: break; } - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); + key_select |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_select}); + if (key != nullptr) { + tx.append(std::span(key, 6)); } char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -249,7 +257,7 @@ uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } // write command part two tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); + tx.append(std::span(data, len)); ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { @@ -268,22 +276,10 @@ uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -295,7 +291,7 @@ uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptrwrite_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != + if (this->write_mifare_classic_block_(current_block, &buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } diff --git a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp index b8fac21bbbe..53874e18de5 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -10,7 +11,7 @@ namespace esphome::pn71xx { static const char *const TAG = "pn71xx.mifare_ultralight"; uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data) != nfc::STATUS_OK) { @@ -42,21 +43,24 @@ uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { return nfc::STATUS_FAILED; } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return nfc::STATUS_FAILED; + } + tag.set_ndef_message(make_unique(std::span(data).subspan(skip))); return nfc::STATUS_OK; } -uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { +uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + const uint8_t page = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + tx.set_payload({nfc::MIFARE_CMD_READ, page}); // a short answer (e.g. a NAK for a page beyond the end of the tag) is retried a limited number of times uint8_t attempts = 0; do { @@ -65,16 +69,15 @@ uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_FAILED; } if (++attempts > NFCC_MAX_COMM_FAILS && rx.get_payload_size() < read_increment) { - ESP_LOGE(TAG, "Short read from page %u", tx.get_message().back()); + ESP_LOGE(TAG, "Short read from page %u", page); return nfc::STATUS_FAILED; } } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); + // the payload ends with a status byte; keep only the bytes still wanted from this read + const uint16_t wanted = num_bytes - i * read_increment; + const size_t count = std::min(read_increment, wanted); + for (const uint8_t byte : rx.get_payload().subspan(0, count)) { + data.push_back(byte); } } @@ -84,7 +87,7 @@ uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_OK; } -bool PN71xx::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN71xx::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -93,7 +96,7 @@ bool PN71xx::is_mifare_ultralight_formatted_(const std::vector &page_3_ } uint16_t PN71xx::read_mifare_ultralight_capacity_() { - std::vector data; + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -101,7 +104,7 @@ uint16_t PN71xx::read_mifare_ultralight_capacity_() { return 0; } -uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -124,33 +127,22 @@ uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::vector &page_3_ uint8_t PN71xx::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return nfc::STATUS_FAILED; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != + if (this->write_mifare_ultralight_page_(current_page, &buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -175,11 +167,9 @@ uint8_t PN71xx::clean_mifare_ultralight_() { } uint8_t PN71xx::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}); + tx.append(std::span(write_data, len)); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error writing page %u", page_num); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1226847fcfc..da03f0e3d16 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -144,6 +144,8 @@ #define PN532_BINARY_SENSOR_COUNT 1 #define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN532_ON_TAG_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pn71xx/pn71xx_test.cpp b/tests/components/pn71xx/pn71xx_test.cpp index ceb2d7305c5..a856400dcdd 100644 --- a/tests/components/pn71xx/pn71xx_test.cpp +++ b/tests/components/pn71xx/pn71xx_test.cpp @@ -12,6 +12,9 @@ namespace { class FakePN71xx : public PN71xx { public: using PN71xx::card_emu_t4t_get_response_; + using PN71xx::discovered_endpoint_; + using PN71xx::erase_tag_; + using PN71xx::find_or_add_tag_; using PN71xx::transceive_; std::deque> to_read; @@ -36,7 +39,8 @@ class FakePN71xx : public PN71xx { this->write_failures--; return nfc::STATUS_FAILED; } - this->written.push_back(tx.encode()); + const auto encoded = tx.encode(); + this->written.emplace_back(encoded.begin(), encoded.end()); return nfc::STATUS_OK; } }; @@ -48,9 +52,9 @@ std::vector apdu(std::initializer_list bytes) { } std::vector respond(FakePN71xx &nfcc, std::initializer_list bytes) { - std::vector response; + CardEmuResponse response; nfcc.card_emu_t4t_get_response_(apdu(bytes), response); - return response; + return {response.begin(), response.end()}; } void select_ndef_file(FakePN71xx &nfcc) { @@ -58,6 +62,10 @@ void select_ndef_file(FakePN71xx &nfcc) { respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}); } +std::vector bytes_of(const nfc::NciMessage &msg) { + return {msg.get_message().begin(), msg.get_message().end()}; +} + const std::vector SW_OK = {0x90, 0x00}; const std::vector SW_NOT_FOUND = {0x6A, 0x82}; @@ -80,7 +88,7 @@ TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) { nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); - EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x06, 0x01, 0x00})); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x06, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } @@ -100,10 +108,65 @@ TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) { nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); - EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x03, 0x01, 0x00})); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x03, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } +nfc::NfcTagUid uid_of(uint8_t last) { return {0x04, 0x00, 0x00, last}; } + +// Erasing an entry keeps the others in order and frees the tag of the slot that is dropped. +TEST(PN71xxTagCache, EraseKeepsOrderAndFreesTail) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < 3; i++) { + nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + } + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 2u); + EXPECT_EQ(nfcc.discovered_endpoint_[0].tag->get_uid()[3], 0); + EXPECT_EQ(nfcc.discovered_endpoint_[1].tag->get_uid()[3], 2); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[2].tag, nullptr); + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 1u); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[1].tag, nullptr); +} + +// A full cache evicts the entry seen longest ago instead of refusing the new tag. +TEST(PN71xxTagCache, FullCacheEvictsOldest) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < MAX_DISCOVERED_ENDPOINTS; i++) { + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + nfcc.discovered_endpoint_[loc].last_seen = 100 + i; + } + nfcc.discovered_endpoint_[3].last_seen = 1; // seen longest ago + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); + EXPECT_EQ(nfcc.discovered_endpoint_[loc].tag->get_uid()[3], 0x99); + for (const auto &endpoint : nfcc.discovered_endpoint_) { + EXPECT_NE(endpoint.tag->get_uid()[3], 3); + } + // a known UID is found, not added again + EXPECT_EQ(nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)), loc); + EXPECT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); +} + +// Bytes that do not fit the packet are dropped and the length byte stays consistent. +TEST(PN71xxNciMessage, AppendStopsAtPacketSize) { + nfc::NciMessage msg(nfc::NCI_PKT_MT_DATA, {0x01}); + std::vector big(300, 0xAA); + msg.append(big); + const auto encoded = msg.encode(); + EXPECT_EQ(encoded.size(), nfc::NCI_PKT_MAX_SIZE); + EXPECT_EQ(msg.get_payload_size(), nfc::NCI_PKT_MAX_PAYLOAD_SIZE); +} + +// A read that could not fit the status bytes into one packet is refused. +TEST(PN71xxCardEmulation, OversizedReadIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/pulse_ce"); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0xFE}), SW_NOT_FOUND); +} + // A refused write (e.g. NFCC in standby) is sent again. TEST(PN71xxTransceive, RefusedWriteIsRetried) { FakePN71xx nfcc; From 32e11c4f4adad680b53472256fe7c10dcc6f1648 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:29:59 +0100 Subject: [PATCH 652/759] [host] Build natively with ninja instead of PlatformIO (#19214) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 36 +- esphome/__main__.py | 23 +- esphome/arduino/library.py | 48 ++- esphome/arduino8266/framework.py | 16 +- esphome/arduino8266/toolchain.py | 74 +--- esphome/build_gen/arduino8266.py | 142 ++----- esphome/build_gen/host.py | 272 +++++++++++++ esphome/build_helpers/ccache.py | 28 +- esphome/build_helpers/native.py | 22 ++ esphome/build_helpers/ninja.py | 96 ++++- esphome/build_helpers/ninja_gen.py | 204 ++++++++++ esphome/build_helpers/tools_cache.py | 9 +- esphome/components/host/__init__.py | 33 +- esphome/const.py | 4 +- esphome/core/__init__.py | 6 + esphome/host/__init__.py | 10 + esphome/host/toolchain.py | 196 ++++++++++ esphome/platformio/library.py | 42 +- esphome/storage_json.py | 2 +- requirements.txt | 2 +- script/build_helpers.py | 21 +- script/cpp_benchmark.py | 30 +- script/cpp_unit_test.py | 30 +- script/determine-jobs.py | 21 +- tests/integration/conftest.py | 126 +++--- .../fixtures/api_action_timeout.yaml | 5 +- tests/integration/fixtures/cache_init.yaml | 10 - .../fixtures/fnv1_hash_object_id.yaml | 5 +- tests/integration/fixtures/fnv1a_hash.yaml | 5 +- .../fixtures/micros_to_millis.yaml | 5 +- tests/script/test_helpers.py | 5 +- .../unit_tests/build_gen/test_arduino8266.py | 5 +- tests/unit_tests/build_gen/test_host.py | 368 ++++++++++++++++++ tests/unit_tests/build_helpers/test_ccache.py | 45 +++ .../unit_tests/build_helpers/test_idedata.py | 33 ++ tests/unit_tests/build_helpers/test_native.py | 34 ++ tests/unit_tests/build_helpers/test_ninja.py | 123 ++++++ .../build_helpers/test_ninja_gen.py | 57 +++ tests/unit_tests/components/host/__init__.py | 0 tests/unit_tests/components/host/test_init.py | 86 ++++ .../unit_tests/test_arduino8266_framework.py | 31 +- .../unit_tests/test_arduino8266_toolchain.py | 208 +++------- tests/unit_tests/test_arduino_library.py | 26 ++ tests/unit_tests/test_core.py | 8 + tests/unit_tests/test_host_toolchain.py | 329 ++++++++++++++++ tests/unit_tests/test_main.py | 212 ++++++++-- tests/unit_tests/test_platformio_library.py | 69 ++++ 47 files changed, 2532 insertions(+), 630 deletions(-) create mode 100644 esphome/build_gen/host.py create mode 100644 esphome/build_helpers/ninja_gen.py create mode 100644 esphome/host/__init__.py create mode 100644 esphome/host/toolchain.py delete mode 100644 tests/integration/fixtures/cache_init.yaml create mode 100644 tests/unit_tests/build_gen/test_host.py create mode 100644 tests/unit_tests/build_helpers/test_native.py create mode 100644 tests/unit_tests/build_helpers/test_ninja_gen.py create mode 100644 tests/unit_tests/components/host/__init__.py create mode 100644 tests/unit_tests/components/host/test_init.py create mode 100644 tests/unit_tests/test_host_toolchain.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c89712e054..4dac899a41a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,14 +400,9 @@ jobs: matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} env: - # What the cache steps persist; libdeps is excluded (keyed per xdist - # worker and env, it never crosses runs). - INTEGRATION_PIO_CACHE_PATH: | - ~/.esphome-integration-tests/platformio/platforms - ~/.esphome-integration-tests/platformio/packages - ~/.esphome-integration-tests/platformio/appstate.json - ~/.esphome-integration-tests/platformio/.cache - ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json + # Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds + # download, shared per xdist worker by tests/integration/conftest.py + INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -427,14 +422,13 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: Restore integration PlatformIO cache - # Native platform + toolchain installed by shared_platformio_cache in - # tests/integration/conftest.py; a miss self-heals, so no restore-keys. - id: pio-cache + - name: Restore integration library cache + # A miss or a changed pin self-heals with a download, so no restore-keys + id: library-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -502,16 +496,16 @@ jobs: # A full cron period of margin for the weekly refresh retention-days: 14 - name: Print ccache statistics - # 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 integration PlatformIO cache + # esphome stores the host build's ccache under the machine-global + # cache dir (see get_build_env() in esphome/host/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s + - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. - if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: ${{ steps.pio-cache.outputs.cache-primary-key }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: ${{ steps.library-cache.outputs.cache-primary-key }} import-time: name: Check import esphome.__main__ time diff --git a/esphome/__main__.py b/esphome/__main__.py index c3502db4bae..8ed97ccae88 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1714,26 +1714,12 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: if exit_code != 0: return exit_code if CORE.is_host: - _LOGGER.info( - "Successfully compiled program to path '%s'", _host_program_path(config) - ) + _LOGGER.info("Successfully compiled program to path '%s'", CORE.firmware_bin) else: _LOGGER.info("Successfully compiled program.") return 0 -def _host_program_path(config: ConfigType) -> str: - """Return the compiled host ELF path.""" - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - return str(toolchain.get_elf_path()) - from esphome.platformio.toolchain import get_idedata - - # Memoized by compile_program's own call; this is a dict lookup - return str(get_idedata(config).firmware_elf_path) - - def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( @@ -1778,7 +1764,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code _LOGGER.info("Successfully compiled program.") if CORE.is_host: - program_path = _host_program_path(config) + program_path = str(CORE.firmware_bin) _LOGGER.info("Running program from path '%s'", program_path) return run_external_process(program_path) @@ -2024,6 +2010,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: CORE.target_platform, ) return 1 + if ( + check_supported := getattr(analysis_toolchain, "check_analysis_supported", None) + ) is not None: + # Raises with the reason; before the compile, not after it + check_supported() # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index e224e625897..143b6d23a07 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -2,7 +2,9 @@ Bundled names build straight from the framework tree; everything else goes through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each -library builds its own archive; all include dirs join one global path. +library builds its own archive; all include dirs join one global path. The +host build reuses it without a framework tree: nothing is bundled there and +every name resolves from the registry. Deviations from PlatformIO: flat-layout libraries get the recursive default source filter; ``dot_a_linkage`` is honored; bundled libraries never run a @@ -342,13 +344,24 @@ def _check_unfulfilled_provides( def resolve_libraries( - framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str + framework_path: Path | None, + *, + pio_platform: str, + board_mcu: str, + cache_key: str, + framework: str | None = "arduino", + manifest_optional: bool = False, ) -> list[ArduinoLibrary]: """Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`. ``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the - shared converter's download cache. + shared converter's download cache. ``framework`` is the manifest + framework token the compatibility check warns about; None skips it. + A None ``framework_path`` means no core-bundled libraries exist (the + host build): every name resolves from the registry. + ``manifest_optional`` accepts libraries without a manifest, built with + PlatformIO's default layout. The returned list is not topologically sorted, so the caller must link the archives inside one ``--start-group``/``--end-group`` pair (the @@ -359,18 +372,22 @@ def resolve_libraries( # PlatformIO's lib_ignore covers framework-bundled libraries too; the # shared converter only filters the registry/git ones. lib_ignore = lib_ignore_set() - # Exact directory names keep membership case-sensitive everywhere - # (an is_dir() probe would match "wire" on macOS/Windows and build - # the bundled Wire twice) - libraries_dir = framework_path / "libraries" - if not libraries_dir.is_dir(): - # A registry fallback would fail later with a misleading - # package-not-found error per bundled name - raise EsphomeError( - f"{libraries_dir} is missing; the framework install may be " - "incomplete (run 'esphome clean-all')" + bundled_dir_names: frozenset[str] = frozenset() + if framework_path is not None: + # Exact directory names keep membership case-sensitive everywhere + # (an is_dir() probe would match "wire" on macOS/Windows and build + # the bundled Wire twice) + libraries_dir = framework_path / "libraries" + if not libraries_dir.is_dir(): + # A registry fallback would fail later with a misleading + # package-not-found error per bundled name + raise EsphomeError( + f"{libraries_dir} is missing; the framework install may be " + "incomplete (run 'esphome clean-all')" + ) + bundled_dir_names = frozenset( + p.name for p in libraries_dir.iterdir() if p.is_dir() ) - bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir()) def _provided(name: object) -> bool: return _is_safe_library_name(name) and name in bundled_dir_names @@ -497,12 +514,13 @@ def resolve_libraries( backend = LibraryBackend( platform=pio_platform, - framework="arduino", + framework=framework, emit=_emit, cache_key=cache_key, # The walk must not resolve bundled names from the registry; # _add_bundled_dependencies adds them after emit provides=_provided, + manifest_optional=manifest_optional, ) if external: convert_libraries(external, backend) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 663002b3b14..d32d61375bb 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -17,7 +17,7 @@ import os from pathlib import Path from typing import NamedTuple -from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.ccache import ccache_env from esphome.build_helpers.ninja import find_ninja from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path from esphome.core import EsphomeError, Version @@ -143,17 +143,5 @@ def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]: *filter(None, env.get("PATH", "").split(os.pathsep)), ] env["PATH"] = os.pathsep.join(parts) - env.update(ccache_env(ccache)) + env.update(ccache_env(ccache, ARDUINO8266_TOOLS_CACHE)) return env - - -def ccache_env(ccache: str | None) -> dict[str, str]: - """Return ccache settings for the build subprocess (not os.environ). - - ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None - when disabled. Values the user already set in the environment are - respected. - """ - if ccache is None: - return {} - return ccache_defaults_env(get_arduino8266_tools_path() / "ccache") diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index 339fb44337f..5bc1889a21b 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -2,21 +2,21 @@ from __future__ import annotations -import json import logging from pathlib import Path import subprocess from typing import TYPE_CHECKING -from esphome.build_helpers.ccache import resolve_ccache_path +from esphome.build_helpers.ccache import resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import refresh_compile_commands from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE, KEY_FRAMEWORK_VERSION, ) -from esphome.core import CORE, EsphomeError -from esphome.helpers import write_file +from esphome.core import CORE from esphome.types import ConfigType if TYPE_CHECKING: @@ -28,20 +28,6 @@ _LOGGER = logging.getLogger(__name__) _MAX_RAM_SIZE = 81920 -def _warn_ignored_platformio_options() -> None: - """Warn for component-added platformio options the native build drops.""" - from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - - consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - for key in sorted(CORE.platformio_options or {}): - if key not in consumed: - _LOGGER.warning( - "platformio_options->%s is ignored when building with the " - "native 'arduino' toolchain", - key, - ) - - _RAM_SECTIONS = (".data", ".rodata", ".bss") _FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata") @@ -84,21 +70,17 @@ def get_readelf_path() -> Path: def run_compile(config: ConfigType, verbose: bool) -> int: from esphome.arduino8266 import framework from esphome.build_gen import arduino8266 as build_gen + from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - _warn_ignored_platformio_options() + warn_ignored_platformio_options(NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS) paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) # Resolved once: the probe is not free and three consumers need it - ccache = resolve_ccache_path() - build_gen.write_project(paths, ccache) + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(paths, ccache) build_dir = get_build_dir() env = framework.get_build_env(paths.toolchain, ccache) - - # The DB is a pure function of build.ninja; regenerate when it is older - compdb = build_dir / "compile_commands.json" - ninja_file = build_dir / "build.ninja" - if not compdb.is_file() or compdb.stat().st_mtime < ninja_file.stat().st_mtime: - _write_compile_commands(paths.ninja, build_dir, env) + refresh_compile_commands(paths.ninja, build_dir, env, ninja_changed) cmd = [str(paths.ninja)] if verbose: @@ -140,42 +122,6 @@ def run_compile(config: ConfigType, verbose: bool) -> int: return 0 -def _write_compile_commands( - ninja_path: Path, build_dir: Path, env: dict[str, str] -) -> None: - compdb = build_dir / "compile_commands.json" - result = subprocess.run( - [str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"], - env=env, - capture_output=True, - text=True, - check=False, - close_fds=False, - ) - if result.returncode != 0: - # Drop any stale database so consumers (IDE integration, clang-tidy, - # the memory analyzer) can't silently read outdated data. - compdb.unlink(missing_ok=True) - raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") - try: - entries = json.loads(result.stdout) - except ValueError as err: - compdb.unlink(missing_ok=True) - raise EsphomeError( - f"ninja produced an unparsable compile database: {err} " - f"(output starts {result.stdout[:120]!r})" - ) from err - if not entries: - # compdb exits 0 with [] for unknown rule names; a renamed compile - # rule must fail the build, not silently strand every consumer - compdb.unlink(missing_ok=True) - raise EsphomeError( - "ninja produced an empty compile database; the generator's rule " - "names no longer match" - ) - write_file(compdb, result.stdout) - - def _parse_app_size(build_dir: Path, paths: InstalledPaths) -> int | None: """Read the app flash budget (irom0_0_seg length) from the linker script.""" from esphome.build_gen.arduino8266 import get_flash_ld_path @@ -253,7 +199,7 @@ def get_idedata(ccache: str | None = None) -> dict | None: # A disabled ccache resolves to None without spawning anything, so # re-resolving here costs nothing when the caller has no answer - launcher = ccache or resolve_ccache_path() + launcher = ccache or resolve_absolute_ccache_path() return load_or_build_idedata( get_build_dir() / "compile_commands.json", get_elf_path(), diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 285379ceea2..4a0e29ab3c9 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -14,7 +14,6 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations -from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -23,7 +22,6 @@ from pathlib import Path import re import shlex import subprocess -import sys from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool @@ -32,6 +30,14 @@ from esphome.build_helpers.ninja import ( quote_path as _q, shell_token as _shell_token, ) +from esphome.build_helpers.ninja_gen import ( + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + tool_lines, +) from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import ( BOARDS, @@ -46,12 +52,9 @@ from esphome.components.esp8266.const import ( KEY_SCANF_FLOAT, ) from esphome.core import CORE, EsphomeError -from esphome.framework_helpers import ( - get_project_cxx_compile_flags, - strip_win_long_path_prefix, -) +from esphome.framework_helpers import get_project_cxx_compile_flags from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags +from esphome.platformio.library import lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths @@ -643,14 +646,6 @@ _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") -def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: - return sorted( - p - for p in root.rglob("*") - if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude - ) - - def _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -868,34 +863,6 @@ def _generate_testing_flash_ld( ) -def _ninja_compile_edges( - lines: list[str], - sources: list[Path], - root: Path, - group: str, - flags: str = "", -) -> list[str]: - """Emit compile edges for ``sources``; return the object paths.""" - objects = [] - for src in sources: - rel = src.relative_to(root).as_posix() - obj = f"obj/{group}/{rel}.o" - escaped_obj = _e(obj) - lines.append( - f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" - ) - if flags: - lines.append(f" flags = {flags}") - # Escaped once here: the returned paths only ever appear in build - # statements (archive/link inputs), which use ninja escaping. - objects.append(escaped_obj) - return objects - - -def _common_parent(paths: list[Path]) -> Path: - return Path(os.path.commonpath([str(p.parent) for p in paths])) - - class _FlagSets(NamedTuple): cflags: list[str] cxxflags: list[str] @@ -1082,48 +1049,14 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] ) - build_tool = Path(__file__).parent / "build_tool.py" - - # $in/$out stay unquoted: ninja escapes its built-in path variables - # itself; only literal paths need _q(). lines = [ - "# Auto-generated by ESPHome", - "ninja_required_version = 1.5", - f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", - f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", - # The NSIS launcher starts Python with a \\?\ extended-length path - # that cmd.exe cannot spawn; same strip every other emitted binary - # path gets - f"python = {_q(strip_win_long_path_prefix(sys.executable))}", - f"buildtool = {_q(build_tool)}", - f"ccache = {_q(ccache) if ccache else ''}", - "", - # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) - "rule c", - " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = CC $out", - "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = CXX $out", - "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = AS $out", - # Plain assembler, as SCons's ASCOM: no preprocessor, so no - # depfile and no $flags (defines/includes) either - "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", - " description = AS $out", - "rule ar", - f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", - " rspfile = $out.rsp", - " rspfile_content = $in_newline", - " description = AR $out", + *tool_lines( + (toolchain_tool(paths.toolchain, "gcc"),), + (toolchain_tool(paths.toolchain, "g++"),), + ccache, + ), + *compile_rule_lines(), + *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", " rspfile = $out.rsp", @@ -1153,16 +1086,15 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: core_exclude |= _CORE_EXCLUDE_WAVEFORM archives = [] - direct_objs: list[str] = [] # variant_dir existence was already enforced with the include dirs - variant_sources = _collect_sources(variant_dir) + variant_sources = collect_sources(variant_dir) if variant_sources: - objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") + objs = compile_edges(lines, variant_sources, variant_dir, "variant") lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") archives.append("libFrameworkArduinoVariant.a") - core_objs = _ninja_compile_edges( - lines, _collect_sources(core_dir, core_exclude), core_dir, "core" + core_objs = compile_edges( + lines, collect_sources(core_dir, core_exclude), core_dir, "core" ) if not core_objs: # An empty archive would link into a wall of undefined references @@ -1173,32 +1105,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") archives.append("libFrameworkArduino.a") - for lib in libraries: - if not lib.sources: - # Header-only libraries are legitimate; the log makes an empty - # srcFilter or broken tree traceable before link errors do. - _LOGGER.debug( - "Library %s has no source files; contributing includes only", - lib.name, - ) - continue - lib_root = _common_parent(lib.sources) - objs = _ninja_compile_edges( - lines, - lib.sources, - lib_root, - f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), - ) - if not lib.lib_archive: - # libArchive: false / dot_a_linkage=false: hand the objects to - # the linker directly so unreferenced-but-required symbols - # (exception handlers, weak overrides) survive - direct_objs.extend(objs) - continue - archive = f"lib{lib.name}.a" - lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") - archives.append(archive) + lib_archives, direct_objs = library_edges(lines, libraries) + archives += lib_archives # One source of truth with the PlatformIO path: esp8266/__init__ pins # build_src_flags (the throw_stubs force-include); -include paths @@ -1221,8 +1129,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {src_extra}") - src_objs = _ninja_compile_edges( - lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags" + src_objs = compile_edges( + lines, collect_sources(src_dir), src_dir, "src", flags="$srcflags" ) ld_deps = [f"ld/{_COMMON_LD_NAME}"] diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py new file mode 100644 index 00000000000..c883d912eb9 --- /dev/null +++ b/esphome/build_gen/host.py @@ -0,0 +1,272 @@ +"""Native ninja build generator for the host platform. + +Emits ``build.ninja`` under ``.pioenvs//``: every source in the +generated ``src/`` tree plus the resolved registry libraries compiles with +the machine's compiler and links into ``program``, the name PlatformIO's +native platform produced. Build flags route the way SCons's ``ParseFlags`` +did under PlatformIO: ``-D``/``-I``/``-std=``/``-W`` shapes reach the +compile lines only, ``-l``/``-L``/``-Wl,`` the link line only, everything +else both. +""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +import os +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.ninja import escape as _e, quote_path as _q, shell_token +from esphome.build_helpers.ninja_gen import ( + PATH_ARG_FLAGS, + Flag, + anchor_path_flag, + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + tool_lines, +) +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_project_cxx_compile_flags +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers, find_tool, get_build_dir +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +# The PlatformIO platform the host built under; registry manifests declare +# compatibility against it, as lib_compat_mode=strict checked before +PIO_PLATFORM = "native" +# Namespaces the shared library download cache (pio_components/host/) +LIBRARY_CACHE_KEY = "host" + +# Flag shapes that only the compiler understands; dropped from the link line +_COMPILE_ONLY_PREFIXES = ("-D", "-U", "-I", "-std=", "-W", *PATH_ARG_FLAGS) +# Flag shapes that only the linker consumes; inert on a -c compile line +_LINK_ONLY_PREFIXES = ("-l", "-L", "-Wl,") +# Link-only flags whose argument is the next token +_LINK_ONLY_ARG_FLAGS = ("-framework", "-Xlinker", "-z") + + +def parse_flags(entries: Iterable[str], owner: str) -> list[Flag]: + """Lex build flag entries into flags, each with its argument. + + Entries are a set, so their order is not the user's: a flag and the + argument it takes as the next token must share one entry. + """ + flags: list[Flag] = [] + for entry in entries: + it = iter(lex_build_flags(entry, owner)) + for tok in it: + if tok not in PATH_ARG_FLAGS and tok not in _LINK_ONLY_ARG_FLAGS: + flags.append((tok,)) + continue + arg = next(it, None) + # A path never starts with "-"; that is the next flag + if arg is None or (tok in PATH_ARG_FLAGS and arg.startswith("-")): + raise EsphomeError( + f"{owner} build flags have '{tok}' with no argument; write " + f"the flag and its argument as one entry" + ) + flags.append((tok, arg)) + return flags + + +def split_flags(flags: list[Flag]) -> tuple[list[Flag], list[Flag]]: + """Route build flags to the compile and link lines.""" + compile_flags: list[Flag] = [] + link_flags: list[Flag] = [] + for flag in flags: + name = flag[0] + if len(flag) > 1: + (compile_flags if name in PATH_ARG_FLAGS else link_flags).append(flag) + elif name.startswith(_LINK_ONLY_PREFIXES): + # Checked before the compile prefixes: -Wl, would match -W + link_flags.append(flag) + elif name.startswith(_COMPILE_ONLY_PREFIXES): + compile_flags.append(flag) + else: + # -g, -O, -f*, -m*, -pthread, --coverage: both lines, as SCons + compile_flags.append(flag) + link_flags.append(flag) + return compile_flags, link_flags + + +def _is_std(flag: Flag) -> bool: + return flag[0].startswith("-std=") + + +def _is_cxx_std(flag: Flag) -> bool: + return _is_std(flag) and "++" in flag[0] + + +def _anchored_flags(entries: Iterable[str], owner: str) -> list[Flag]: + build_path = Path(CORE.build_path) + return [ + anchor_path_flag(flag, build_path) + for flag in parse_flags(sorted(entries), owner) + ] + + +def _flag_lists() -> tuple[list[str], list[str], list[str]]: + """The C, C++, and link flag lists (raw tokens), build_unflags applied. + + ``cg.set_cpp_standard`` wins over any ``-std=`` in the build flags for + C++ compiles, as PlatformIO's unflag of every other standard did; C + compiles never see a C++ standard. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + compile_flags, link_flags = split_flags( + _anchored_flags(CORE.build_flags, "esphome") + ) + cflags = [f for f in compile_flags if not _is_cxx_std(f)] + cxx_std = CORE.cpp_standard + cxxflags = [f for f in compile_flags if not (cxx_std and _is_std(f))] + if cxx_std: + cxxflags.insert(0, (f"-std={cxx_std}",)) + cxxflags += [(tok,) for tok in get_project_cxx_compile_flags()] + + # A flag is removed whole, with its argument, as PlatformIO did + unflags = set(_anchored_flags(CORE.build_unflags, "esphome build_unflags")) + # An unflag that hits nothing (a typo, or -DUSE_FOO against + # -DUSE_FOO=1) must be visible, since the user believes the flag is + # gone while it still drives the build + if unmatched := sorted(unflags - set(cflags) - set(cxxflags) - set(link_flags)): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", + ", ".join(" ".join(flag) for flag in unmatched), + ) + + def keep(flags: list[Flag]) -> list[str]: + return [tok for flag in flags if flag not in unflags for tok in flag] + + return keep(cflags), keep(cxxflags), keep(link_flags) + + +def _resolve_host_libraries() -> list[ArduinoLibrary]: + """Every ``cg.add_library()`` entry, fetched from the registry. + + The host has no framework, so nothing is bundled and no framework + compatibility check applies; the platform check keeps the strict + manifest gate PlatformIO's native platform enforced. Manifest-less + libraries (a bare git checkout) build with PlatformIO's default + layout, as they did under its native platform. + """ + if not CORE.platformio_libraries: + return [] + from esphome.arduino.library import resolve_libraries + + return resolve_libraries( + None, + pio_platform=PIO_PLATFORM, + board_mcu="host", + cache_key=LIBRARY_CACHE_KEY, + framework=None, + manifest_optional=True, + ) + + +def _file_macro_maps(build_dir: Path) -> list[str]: + """Flags that keep ``__FILE__`` relative to the build path. + + PlatformIO compiled ``src/x.cpp`` from the build path, and tools name + things after that spelling (CodSpeed's benchmark ids). Here a source + reaches the compiler by its absolute path, or relative to the build + directory when ccache rewrites it. + """ + build_path = Path(CORE.build_path) + prefixes = (build_path, Path(os.path.relpath(build_path, build_dir))) + return [ + shell_token(f"-fmacro-prefix-map={prefix}{os.sep}=", force=True) + for prefix in prefixes + ] + + +def write_project(compilers: HostCompilers, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled). + Returns True when ``build.ninja`` changed, so the caller can skip work + derived purely from it (the compile database) on unchanged builds. + """ + build_dir = get_build_dir() + mkdir_p(build_dir) + src_dir = CORE.relative_src_path() + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + + cflags, cxxflags, link_flags = _flag_lists() + libraries = _resolve_host_libraries() + + include_dirs = [src_dir] + for lib in libraries: + include_dirs += lib.include_dirs + includes = [f"-I{_q(d)}" for d in include_dirs] + includes += _file_macro_maps(build_dir) + + # SCons's link line: $LINKFLAGS $SOURCES $_LIBDIRFLAGS $_LIBFLAGS, so + # -L and -l trail the objects while every other link token leads + lib_dirs = [Path(t[2:]) for t in link_flags if t.startswith("-L")] + libs = [t for t in link_flags if t.startswith("-l")] + linkflags = [shell_token(t) for t in link_flags if not t.startswith(("-L", "-l"))] + for lib in libraries: + lib_dirs += lib.link_dirs + libs += [f"-l{name}" for name in lib.link_libs] + linkflags += [shell_token(f) for f in lib.link_flags] + + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly + asflags = [t for t in cflags if t.startswith(("-D", "-I"))] + + lines = [ + *tool_lines(compilers.cc, compilers.cxx, ccache), + *compile_rule_lines(), + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + ] + if any(lib.sources and lib.lib_archive for lib in libraries): + # Resolved only when an archive is built, so a system without + # binutils still links a library-free configuration + lines += ar_rule_lines(find_tool("AR", ("ar",))) + lines += [ + "", + f"cflags = {' '.join([*map(shell_token, cflags), *includes])}", + f"cxxflags = {' '.join([*map(shell_token, cxxflags), *includes])}", + f"asflags = {' '.join([*map(shell_token, asflags), *includes])}", + f"linkflags = {' '.join(linkflags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(shell_token(lib) for lib in libs)}", + "", + ] + + archives, direct_objs = library_edges(lines, libraries) + + src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src") + if not src_objs: + raise EsphomeError(f"No source files found under {src_dir}") + + # Archives are not topologically sorted; GNU ld needs the group to + # resolve references between them. ld64 loads archives iteratively and + # rejects the option, so macOS lists them bare. + archive_tokens = [shell_token(a) for a in archives] + if archive_tokens and sys.platform != "darwin": + archive_tokens = ["-Wl,--start-group", *archive_tokens, "-Wl,--end-group"] + lines.append( + f"build {PROGRAM_NAME}: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)}" + ) + lines.append(f" archives = {' '.join(archive_tokens)}") + lines.append(f"default {PROGRAM_NAME}") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 5b5c7f247fb..541dcbe3d33 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -68,6 +68,15 @@ def resolve_ccache_path() -> str | None: return ccache +def resolve_absolute_ccache_path() -> str | None: + """``resolve_ccache_path`` for the ninja backends, which run their + commands from the build directory, where a relative path is lost.""" + from esphome.build_helpers.ninja import absolute_tool + + ccache = resolve_ccache_path() + return absolute_tool(ccache) if ccache else None + + def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: """Default ``CCACHE_*`` values for a build subprocess (not os.environ). @@ -84,9 +93,24 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: "CORE.build_path must be set before constructing the build environment" ) defaults = { - "CCACHE_DIR": str(cache_dir), + # ccache expands $VAR in its settings; $$ is a literal $ + "CCACHE_DIR": str(cache_dir).replace("$", "$$"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", - "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()).replace("$", "$$"), } return {k: v for k, v in defaults.items() if k not in os.environ} + + +def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]: + """The ccache settings for a build subprocess (not os.environ). + + ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when + disabled; ``tools_cache`` is the backend's tools cache spec, which holds + its ccache dir. + """ + if ccache is None: + return {} + from esphome.build_helpers.tools_cache import tools_cache_path + + return ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache") diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py index 24499c3ccde..1d2dc1b3cae 100644 --- a/esphome/build_helpers/native.py +++ b/esphome/build_helpers/native.py @@ -7,16 +7,21 @@ platform component package or the backend itself. from __future__ import annotations +from collections.abc import Collection import importlib +import logging from types import ModuleType from esphome.const import Toolchain from esphome.core import CORE, EsphomeError +_LOGGER = logging.getLogger(__name__) + # Native build backend per (target platform, toolchain) NATIVE_TOOLCHAIN_MODULES = { ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", + ("host", Toolchain.HOST): "esphome.host.toolchain", } @@ -50,3 +55,20 @@ def analysis_backend() -> ModuleType | None: return native module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) return importlib.import_module(module_path) if module_path else None + + +def warn_ignored_platformio_options(consumed: Collection[str]) -> None: + """Warn for component-added platformio options a native build drops. + + User-supplied keys were already routed or warned about by + ``core/config.py``; what survives into ``CORE.platformio_options`` came + from ``cg.add_platformio_option`` calls in components. + """ + for key in sorted(CORE.platformio_options or {}): + if key not in consumed: + _LOGGER.warning( + "platformio_options->%s is ignored when building with the " + "native '%s' toolchain", + key, + CORE.toolchain.value, + ) diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 8c25bc95130..7634b0f2f34 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -2,14 +2,17 @@ from __future__ import annotations +import json import logging import os from pathlib import Path import re import shutil +import subprocess from esphome.core import EsphomeError from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs +from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -23,11 +26,26 @@ def _ninja_runs(binary: str) -> bool: ) +# Compile rule names the generators emit; ninja's compdb tool is asked for +# exactly these, so a renamed rule fails the build instead of stranding idedata +COMPILE_RULES = ("c", "cxx", "aspp", "asm") + + +def absolute_tool(tool: str | Path) -> str: + """A tool path that still resolves from the build directory. + + ``shutil.which`` returns a relative path for a relative PATH entry, and + ninja runs the commands from ``.pioenvs/``. Symlinks are kept: + ccache's compiler links depend on the name they are called by. + """ + return strip_win_long_path_prefix(str(Path(tool).absolute())) + + def find_ninja() -> Path: """Locate the ninja binary: a runnable PATH hit first, else the ninja PyPI wheel.""" if binary := shutil.which("ninja"): - binary = strip_win_long_path_prefix(binary) + binary = absolute_tool(binary) if _ninja_runs(binary): return Path(binary) import_error: ImportError | None = None @@ -49,8 +67,18 @@ def find_ninja() -> Path: def escape(value: Path | str) -> str: - """Escape a path or token for a ninja file.""" - return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + """Escape a path or token for a ninja file. + + ninja has no escape for ``|`` or a line break in a path, so those fail + here by name instead of producing a build file ninja misreads. + """ + text = str(value) + if bad := next((c for c in "|\r\n" if c in text), None): + raise EsphomeError( + f"Path {text!r} contains {bad!r}, which a ninja build file cannot " + "express; rename the file or directory" + ) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") def quote_arg(tok: str) -> str: @@ -90,3 +118,65 @@ def shell_token(tok: str, force: bool = False) -> str: def quote_path(value: Path | str) -> str: """Force-quote a path for the ninja command line (shell/CreateProcess).""" return shell_token(str(value), force=True) + + +def refresh_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str], ninja_changed: bool +) -> None: + """Regenerate the compile DB (a pure function of build.ninja) when stale. + + Freshness rides a stamp: the DB itself is written through + write_file_if_changed (its mtime feeds the idedata cache), so a + regeneration with identical content would stay "stale" forever. An + interrupted previous run may have rewritten the manifest without + regenerating the DB, hence the mtime comparison. + """ + compdb = build_dir / "compile_commands.json" + compdb_stamp = build_dir / ".compile_commands.stamp" + ninja_file = build_dir / "build.ninja" + if ( + ninja_changed + or not compdb.is_file() + or not compdb_stamp.is_file() + or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime + ): + write_compile_commands(ninja_path, build_dir, env) + compdb_stamp.touch() + + +def write_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str] +) -> None: + compdb = build_dir / "compile_commands.json" + result = subprocess.run( + [str(ninja_path), "-C", str(build_dir), "-t", "compdb", *COMPILE_RULES], + env=env, + capture_output=True, + text=True, + check=False, + close_fds=False, + ) + if result.returncode != 0: + # Drop any stale database so consumers (IDE integration, clang-tidy, + # the memory analyzer) can't silently read outdated data + compdb.unlink(missing_ok=True) + raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") + try: + entries = json.loads(result.stdout) + except ValueError as err: + compdb.unlink(missing_ok=True) + raise EsphomeError( + f"ninja produced an unparsable compile database: {err} " + f"(output starts {result.stdout[:120]!r})" + ) from err + if not entries: + # compdb exits 0 with [] for unknown rule names; a renamed compile + # rule must fail the build, not silently strand every consumer + compdb.unlink(missing_ok=True) + raise EsphomeError( + "ninja produced an empty compile database; the generator's rule " + "names no longer match" + ) + # write_file_if_changed keeps the mtime stable on no-op builds so the + # idedata cache stays valid + write_file_if_changed(compdb, result.stdout) diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py new file mode 100644 index 00000000000..d4cfc301ac5 --- /dev/null +++ b/esphome/build_helpers/ninja_gen.py @@ -0,0 +1,204 @@ +"""The parts of a ``build.ninja`` every native backend emits the same way. + +Rule names match ``SOURCE_KIND_FOR_SUFFIX`` values (c, cxx, asm, aspp) and +``ninja.COMPILE_RULES``, which the compile database is asked for. +""" + +from __future__ import annotations + +from collections.abc import Collection, Sequence +import logging +import os +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) +from esphome.framework_helpers import strip_win_long_path_prefix +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +_BUILD_TOOL = Path(__file__).parent.parent / "build_gen" / "build_tool.py" + + +def collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + +def common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +def _command(words: Sequence[Path | str]) -> str: + program, *args = words + return " ".join([_q(program), *(_shell_token(str(a)) for a in args)]) + + +def tool_lines( + cc: Sequence[Path | str], cxx: Sequence[Path | str], ccache: str | None +) -> list[str]: + """The file header: the compilers and the helper tools as variables. + + A compiler is its program followed by any arguments it always takes. + """ + return [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_command(cc)}", + f"cxx = {_command(cxx)}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(_BUILD_TOOL)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + ] + + +def compile_rule_lines() -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" + return [ + "rule c", + " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + ] + + +def ar_rule_lines(ar: Path | str) -> list[str]: + return [ + "rule ar", + f" command = $python $buildtool ar {_q(ar)} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + ] + + +def compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", + own_includes: str = "", +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths.""" + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + lines.append( + f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" + ) + if own_includes: + lines.append(f" own_includes = {own_includes}") + if flags: + lines.append(f" flags = {flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping + objects.append(escaped_obj) + return objects + + +def library_edges( + lines: list[str], libraries: list[ArduinoLibrary] +) -> tuple[list[str], list[str]]: + """Emit every library's compile and archive edges. + + Returns the archive names and the objects that link directly. A + library's own include dirs lead its compile lines, as PlatformIO searched + them first: the include path is one global list, so another library's + header of the same name would shadow them. + """ + archives: list[str] = [] + direct_objs: list[str] = [] + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + objs = compile_edges( + lines, + lib.sources, + common_parent(lib.sources), + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + return archives, direct_objs + + +# One build flag: the flag, plus its argument when that is a separate token +Flag = tuple[str, ...] + +# Flags whose path operand is the next token; gcc also takes it glued on +PATH_ARG_FLAGS = ("-include", "-imacros", "-isystem", "-iquote", "-idirafter") +# Flags whose path operand is glued on +PATH_PREFIXES = ("-I", "-L", *PATH_ARG_FLAGS) + + +def _anchor(path: str, base: Path) -> str: + if not path or Path(path).is_absolute(): + return path + return str(base / path) + + +def anchor_path_flag(flag: Flag, base: Path) -> Flag: + """Anchor a flag's relative path operand at ``base``. + + PlatformIO ran the compiler from the build path; ninja runs it from + ``.pioenvs/``, where a relative operand would point elsewhere. + """ + name, *args = flag + if args: + if name in PATH_ARG_FLAGS: + return (name, _anchor(args[0], base)) + return flag + for prefix in PATH_PREFIXES: + if name.startswith(prefix): + return (prefix + _anchor(name[len(prefix) :], base),) + return flag diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index e7193a8e2af..2dbbb2ffc22 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -33,4 +33,11 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf") SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") -TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE) +# The host backend installs nothing; the entry only holds its ccache dir +HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host") +TOOLS_CACHE_SPECS = ( + IDF_TOOLS_CACHE, + SDK_NRF_TOOLS_CACHE, + ARDUINO8266_TOOLS_CACHE, + HOST_TOOLS_CACHE, +) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index bd074ab6b58..16e4c81d344 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -1,3 +1,5 @@ +import sys + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -8,9 +10,9 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_HOST, ThreadModel, + Toolchain, ) -from esphome.core import CORE -from esphome.platformio.toolchain import copy_ccache_script +from esphome.core import CORE, EsphomeError from esphome.types import ConfigType from .const import KEY_HOST @@ -37,7 +39,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), - cv.require_platformio_toolchain("host"), + # The host builds with the machine's own compiler through ninja; there + # is no PlatformIO backend, so a CLI --toolchain must name this one + cv.resolve_toolchain("host", (Toolchain.HOST,), Toolchain.HOST), set_core_data, ) @@ -48,16 +52,23 @@ async def to_code(config: ConfigType) -> None: # The prefs file finds stored preferences by key, so key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) - cg.add_build_flag("-std=gnu++20") + cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", "host") cg.add_define("ESPHOME_VARIANT", "HOST") cg.add_define(ThreadModel.MULTI_ATOMICS) - cg.add_platformio_option("platform", "platformio/native") - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) -# Called by writer.py -def copy_files() -> None: - copy_ccache_script() +# Called by __main__.compile_program; True means this platform built the +# program itself instead of falling through to the PlatformIO toolchain. +def run_compile(args: object, config: ConfigType) -> bool: + from esphome.host import toolchain + + if sys.platform == "win32": + # Here, not in validation: `esphome config` must keep working + raise EsphomeError( + "The host platform uses POSIX interfaces and does not build on " + "Windows; use WSL or a Linux or macOS machine" + ) + if toolchain.run_compile(config, CORE.verbose) != 0: + raise EsphomeError("Host build failed") + return True diff --git a/esphome/const.py b/esphome/const.py index 5ffbf8c49ac..fb02d702810 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -23,12 +23,14 @@ class Toolchain(StrEnum): SDK_NRF = "sdk-nrf" # ESP8266: the Arduino core built directly (no PlatformIO) ARDUINO = "arduino" + # host: the system C/C++ compiler driven by ninja (no PlatformIO) + HOST = "host" # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. -NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST}) class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index fb752850817..fa3d6bb64ed 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -996,6 +996,12 @@ class EsphomeCore: ``using_arduino``, which is the target framework).""" return self.toolchain == Toolchain.ARDUINO + @property + def using_toolchain_host(self): + """The native host build toolchain: the system compiler driven by + ninja (the only toolchain the host platform serves).""" + return self.toolchain == Toolchain.HOST + @property def using_native_toolchain(self): """Whether the selected toolchain builds natively, without reading diff --git a/esphome/host/__init__.py b/esphome/host/__init__.py new file mode 100644 index 00000000000..003b47eb99a --- /dev/null +++ b/esphome/host/__init__.py @@ -0,0 +1,10 @@ +"""Native (PlatformIO-free) build support for the host platform. + +Builds the generated sources with the machine's own C/C++ compiler through a +ninja file and drives the build directly -- the host equivalent of +``esphome.espidf``. Nothing is downloaded: the compiler comes from PATH and +registry libraries go through the shared library converter. + +Deliberately importable without the host component to avoid circular +imports; the component wires these modules in via lazy imports. +""" diff --git a/esphome/host/toolchain.py b/esphome/host/toolchain.py new file mode 100644 index 00000000000..47b5bda48c7 --- /dev/null +++ b/esphome/host/toolchain.py @@ -0,0 +1,196 @@ +"""Native host build driver (the PlatformIO ``run`` equivalent). + +The compiler and binutils come from PATH (``CC``/``CXX``/``AR``/``OBJDUMP``/ +``READELF`` override the lookup, like make and CMake), ninja from PATH or +the ninja PyPI wheel, and ccache is used when found. The build lives under +``.pioenvs//`` so ``CORE.firmware_bin`` and the clean paths stay the +ones the PlatformIO build used. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +from typing import NamedTuple + +from esphome.build_helpers.ccache import ccache_env, resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import ( + absolute_tool, + find_ninja, + refresh_compile_commands, +) +from esphome.build_helpers.tools_cache import HOST_TOOLS_CACHE +from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME +from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# The output name PlatformIO's native platform produced; CORE.firmware_bin +# and the integration-test harness resolve it by this name +PROGRAM_NAME = "program" + +# platformio_options keys the host build reads (lib_ignore feeds the library +# converter); anything else has no native equivalent and is warned about +CONSUMED_PIO_OPTIONS = frozenset({"lib_ignore"}) + + +class HostCompilers(NamedTuple): + """The resolved C and C++ compiler commands: the program, then any + arguments its override carried (``CC="gcc -m32"``).""" + + cc: tuple[str, ...] + cxx: tuple[str, ...] + + +def find_command(env_var: str, candidates: tuple[str, ...]) -> tuple[str, ...]: + """Resolve a build tool: ``env_var`` when set, else the first candidate + found on PATH. Returns the program and the override's arguments. + + An override that does not resolve fails by name rather than falling back + silently to a different compiler than the user asked for. + """ + if override := os.environ.get(env_var, "").strip(): + program, *args = shlex.split(override) + if Path(program).stem == "ccache" and args: + # The build adds ccache itself; twice would hide the compiler + program, *args = args + # which() accepts an absolute path as well as a bare program name + if (resolved := shutil.which(program)) is None: + raise EsphomeError( + f"{env_var}={override!r} does not name a runnable program" + ) + return (absolute_tool(resolved), *args) + for name in candidates: + if (found := shutil.which(name)) is not None: + return (absolute_tool(found),) + raise EsphomeError( + f"{candidates[0]} not found on PATH (tried {', '.join(candidates)}); " + f"install it or set {env_var}" + ) + + +def find_tool(env_var: str, candidates: tuple[str, ...]) -> str: + """Resolve a build tool that is called as a bare program.""" + program, *args = find_command(env_var, candidates) + if args: + raise EsphomeError(f"{env_var} must name a program without arguments") + return program + + +def find_compilers() -> HostCompilers: + """The C and C++ compilers the build uses (gcc first, as PlatformIO did).""" + return HostCompilers( + cc=find_command("CC", ("gcc", "clang", "cc")), + cxx=find_command("CXX", ("g++", "clang++", "c++")), + ) + + +def get_build_dir() -> Path: + return CORE.relative_pioenvs_path(CORE.name) + + +def get_elf_path() -> Path: + return get_build_dir() / PROGRAM_NAME + + +def get_objdump_path() -> Path: + return Path(find_tool("OBJDUMP", ("objdump",))) + + +def get_readelf_path() -> Path: + return Path(find_tool("READELF", ("readelf",))) + + +def check_analysis_supported() -> None: + """Refuse analyze-memory where the program is not an ELF file. + + Called before the compile, so an unsupported machine fails at once. + """ + if sys.platform != "linux": + raise EsphomeError( + "analyze-memory reads ELF files; the host build on " + f"{sys.platform} produces a different format" + ) + + +def get_build_env(ccache: str | None) -> dict[str, str]: + return {**os.environ, **ccache_env(ccache, HOST_TOOLS_CACHE)} + + +def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.build_gen import host as build_gen + + warn_ignored_platformio_options(CONSUMED_PIO_OPTIONS) + # Probe the cheap local dependencies before resolving libraries + ninja_path = find_ninja() + compilers = find_compilers() + # Resolved once per build: the resolution probes PATH and spawns the + # runnability check, and three consumers need the same answer + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(compilers, ccache) + + build_dir = get_build_dir() + env = get_build_env(ccache) + refresh_compile_commands(ninja_path, build_dir, env, ninja_changed) + + cmd = [str(ninja_path)] + if verbose: + cmd.append("-v") + if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT): + cmd += ["-j", str(jobs)] + # The explicit target, not the default statement: a generator defect + # that drops it fails loudly with "unknown target" instead of a green + # no-op run that leaves a stale program in place + cmd.append(PROGRAM_NAME) + + _LOGGER.debug("Running: %s", " ".join(cmd)) + # cwd instead of -C also drops the "Entering directory" banner + rc = subprocess.run( + cmd, cwd=build_dir, env=env, check=False, close_fds=False + ).returncode + if rc != 0: + return rc + + elf = get_elf_path() + if not elf.is_file(): + # ninja refused a manifest missing the target above; this covers a + # rule that ran but wrote elsewhere + _LOGGER.error("Build produced no %s", elf) + return 1 + + from esphome.build_helpers.idedata import warn_if_idedata_missing + + warn_if_idedata_missing(lambda: _load_idedata(ccache)) + return 0 + + +def get_idedata() -> dict | None: + """Derive idedata from the build's compile_commands.json. + + Same contract as ``espidf.toolchain.get_idedata``: the fields IDE + integrations, clang-tidy, and the memory analyzer expect. Returns None + when nothing has been built yet. + """ + # Deliberately uncached: env/PATH can change between builds in a + # long-lived host process + return _load_idedata(resolve_absolute_ccache_path()) + + +def _load_idedata(ccache: str | None) -> dict | None: + from esphome.build_helpers.idedata import load_or_build_idedata + + return load_or_build_idedata( + get_build_dir() / "compile_commands.json", + get_elf_path(), + CORE.relative_internal_path("idedata", f"{CORE.name}.json"), + # The compile DB's commands carry the same ccache prefix the ninja + # rules were generated with + launcher=ccache, + ) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index fb6779b8078..af2010711f6 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -360,7 +360,7 @@ class LibraryBackend: """ platform: str | None - framework: str + framework: str | None emit: Callable[["ConvertedLibrary"], None] cache_key: str # Owner-less names this returns True for are skipped by the walk; @@ -368,6 +368,10 @@ class LibraryBackend: # reconciles provided_requests after resolving provides: Callable[[str], bool] | None = None provided_requests: set[str] = field(default_factory=set) + # Accept a library without library.json/library.properties, as + # PlatformIO does (its defaults: src/ or the root, plus include/). Off + # for backends whose emitted build files need the manifest. + manifest_optional: bool = False def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -859,6 +863,11 @@ def _url_or_none(value: Any) -> str | None: return value if parsed.scheme and parsed.netloc else None +# URL schemes that name a local library folder; symlink:// is PlatformIO's +# spelling for one it links instead of copying, which is the same to us +_LOCAL_SCHEMES = ("file", "symlink") + + def _node_key( name: str | None, version: str | None, repository: str | None ) -> tuple[str, str, tuple[str | None, str | None]]: @@ -896,7 +905,7 @@ def _node_key( scheme = urlsplit(candidate).scheme except ValueError: scheme = "" - if scheme == "file" or _url_or_none(candidate): + if scheme in _LOCAL_SCHEMES or _url_or_none(candidate): name, repository = custom_name, candidate else: # Anything with ``://`` was meant to be a URL; failing it fast @@ -905,7 +914,7 @@ def _node_key( if repository: is_git_prefixed = repository.startswith("git+") split_result = urlsplit(repository.removeprefix("git+")) - if split_result.scheme == "file" and not is_git_prefixed: + if (scheme := split_result.scheme) in _LOCAL_SCHEMES and not is_git_prefixed: # A plain file:// URL points at a local library directory. A local # file URL is written file:///absolute/path (empty host) or, less # commonly, file://localhost/path. Anything else -- a real host, or @@ -913,8 +922,8 @@ def _node_key( # rejected rather than silently resolved to the wrong directory. if split_result.netloc not in ("", "localhost"): raise RuntimeError( - f"Unsupported host in file:// library URL '{repository}'; " - "use an absolute path, e.g. file:///path/to/lib" + f"Unsupported host in {scheme}:// library URL '{repository}'; " + f"use an absolute path, e.g. {scheme}:///path/to/lib" ) # Validate the URL path itself (always POSIX-style, leading slash), # not the OS path: on Windows a "/foo" path is not is_absolute() @@ -924,8 +933,8 @@ def _node_key( url_path = split_result.path if not url_path.startswith("/") or not PurePosixPath(url_path).name: raise RuntimeError( - f"file:// library URL '{repository}' must be an absolute " - "directory path, e.g. file:///path/to/lib" + f"{scheme}:// library URL '{repository}' must be an absolute " + f"directory path, e.g. {scheme}:///path/to/lib" ) path = url2pathname(url_path) return (name or PurePosixPath(url_path).name), "local", (path, None) @@ -1244,9 +1253,16 @@ def convert_libraries( library_properties_path = source_dir / "library.properties" has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() - if not has_json and not has_properties and not node.is_local: + if ( + not has_json + and not has_properties + and not node.is_local + and not backend.manifest_optional + ): # An interrupted clone/extraction self-heals with one forced - # re-download; a local source has nothing to re-download + # re-download; a local source has nothing to re-download. + # A backend accepting manifest-less libraries cannot tell + # one from a torn download and would re-fetch every build. _LOGGER.warning( "Library %s at %s is missing library.json and library.properties; " "re-downloading", @@ -1260,6 +1276,12 @@ def convert_libraries( component.data = parse_library_json(library_json_path) elif has_properties: component.data = parse_library_properties(library_properties_path) + elif backend.manifest_optional: + _LOGGER.debug( + "Library %s has no manifest; using PlatformIO's default layout", + key, + ) + component.data = {"name": component.name} else: # Local sources are user input (EsphomeError); a registry/git # miss means a corrupt cache (RuntimeError) @@ -1286,7 +1308,7 @@ def convert_libraries( # cross-platform skip stays at debug, other causes warn if key in top_level_keys: reason = ( - f"is not compatible with {backend.framework}" + f"is not compatible with {backend.framework or backend.platform}" if isinstance(e, IncompatiblePlatform) else "has a malformed manifest" ) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9219914529b..adaa809d6e3 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -155,7 +155,7 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform - # The toolchain used for the build ("platformio" / "esp-idf") + # The toolchain used for the build ("platformio" / "esp-idf" / "host") self.toolchain = toolchain # The area of the node self.area = area diff --git a/requirements.txt b/requirements.txt index ff0ad9c944a..81f3cbcdd2d 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.12 # native esp-idf toolchain global cache dir -ninja==1.13.2 # native esp8266 arduino toolchain build driver +ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/script/build_helpers.py b/script/build_helpers.py index b4b25924c3b..f0b354a8a7f 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -22,8 +22,8 @@ from esphome.__main__ import command_compile, parse_args from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE +from esphome.host.toolchain import get_elf_path from esphome.loader import get_component, get_platform -from esphome.platformio.toolchain import get_idedata from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini @@ -220,7 +220,7 @@ def create_host_config( friendly_name: str, libraries: str | list[str], includes: list[str], - platformio_options: dict, + build_flags: list[str], ) -> dict: """Create an ESPHome host configuration for C++ builds. @@ -229,7 +229,7 @@ def create_host_config( friendly_name: Human-readable name libraries: PlatformIO library specification(s) includes: List of include folders for the build - platformio_options: Dict of platformio_options to set + build_flags: Compiler/linker flags for the build Returns: Configuration dict for ESPHome @@ -239,7 +239,7 @@ def create_host_config( "name": config_name, "friendly_name": friendly_name, "libraries": libraries, - "platformio_options": platformio_options, + "build_flags": build_flags, "includes": includes, }, HOST_KEY: {}, @@ -403,13 +403,12 @@ def compile_and_get_binary( return EXIT_COMPILE_ERROR, None # After a successful compilation, locate the executable: - idedata = get_idedata(config) - if idedata is None: + program_path = get_elf_path() + if not program_path.is_file(): print("Cannot find executable") return EXIT_NO_EXECUTABLE, None - program_path: str = idedata.raw["prog_path"] - return EXIT_OK, program_path + return EXIT_OK, str(program_path) def build_and_run( @@ -419,7 +418,7 @@ def build_and_run( config_prefix: str, friendly_name: str, libraries: str | list[str], - platformio_options: dict, + build_flags: list[str], main_entry: str, label: str = "build", build_only: bool = False, @@ -438,7 +437,7 @@ def build_and_run( config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench") friendly_name: Human-readable name for the config libraries: PlatformIO library specification(s) - platformio_options: PlatformIO options dict + build_flags: Compiler/linker flags for the build main_entry: Name of the main entry file (e.g. "main.cpp") label: Label for log messages build_only: If True, print binary path and return without running @@ -488,7 +487,7 @@ def build_and_run( config_name: str = f"{config_prefix}-" + hash_components(components) config = create_host_config( - config_name, friendly_name, libraries, includes, platformio_options + config_name, friendly_name, libraries, includes, build_flags ) exit_code, program_path = compile_and_get_binary( diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 5080a9fec74..678356496e4 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -25,19 +25,14 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" # allow benchmarks to compile on the host platform. STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" -PLATFORMIO_OPTIONS = { - "build_flags": [ - "-Os", # match firmware optimization level (detects inlining regressions) - "-g", # debug symbols for profiling - "-ffunction-sections", # required for dead-code stripping with -Os - "-fdata-sections", # required for dead-code stripping with -Os - "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() - f"-I{STUBS_DIR}", # stub headers for ESP32-only components - ], - # Use deep+ LDF mode to ensure PlatformIO detects the benchmark - # library dependency from nested includes. - "lib_ldf_mode": "deep+", -} +BUILD_FLAGS = [ + "-Os", # match firmware optimization level (detects inlining regressions) + "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os + "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components +] def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int: @@ -46,7 +41,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> # containing {"lib_path": "/path/to/google_benchmark"}. lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG") - pio_options = PLATFORMIO_OPTIONS + build_flags = BUILD_FLAGS if lib_config_json: lib_config = json.loads(lib_config_json) benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}" @@ -61,10 +56,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> "-DCODSPEED_ANALYSIS", f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"', ] - pio_options = { - **PLATFORMIO_OPTIONS, - "build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags, - } + build_flags = BUILD_FLAGS + codspeed_flags else: benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB @@ -77,7 +69,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> config_prefix="cppbench", friendly_name="CPP Benchmarks", libraries=benchmark_lib, - platformio_options=pio_options, + build_flags=build_flags, main_entry="main.cpp", label="benchmarks", build_only=build_only, diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 8cb18d08757..c5179a1b1e9 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -15,24 +15,16 @@ from helpers import get_all_components, root_path # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" -PLATFORMIO_OPTIONS = { - "build_type": "debug", - "build_unflags": [ - "-Os", # remove size-opt flag - ], - "build_flags": [ - "-Og", # optimize for debug - "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", - "-fno-omit-frame-pointer", - ], - "debug_build_flags": [ # only for debug builds - "-g3", # max debug info - "-ggdb3", - ], -} +BUILD_FLAGS = [ + "-Og", # optimize for debug + "-g3", # max debug info + "-ggdb3", + "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", +] def run_tests(selected_components: list[str]) -> int: @@ -47,7 +39,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - platformio_options=PLATFORMIO_OPTIONS, + build_flags=BUILD_FLAGS, main_entry="main.cpp", label="unit tests", ) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index a45951a52a2..3f0143076b2 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -111,14 +111,25 @@ INTEGRATION_TESTS_SPLIT_THRESHOLD = 10 INTEGRATION_TESTS_SPLIT_BUCKETS = 5 INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0 -# platformio and aioesphomeapi (requirements.txt), the pytest stack -# (requirements_test.txt) and the fixture every session compiles; a change -# to any runs the full matrix +# aioesphomeapi (requirements.txt), the pytest stack (requirements_test.txt) +# and the native host build backend every test compiles with; a change to +# any runs the full matrix INTEGRATION_TESTS_TRIGGER_FILES = frozenset( { "requirements.txt", "requirements_test.txt", - "tests/integration/fixtures/cache_init.yaml", + "esphome/arduino/library.py", + "esphome/build_gen/build_tool.py", + "esphome/build_gen/host.py", + "esphome/build_helpers/ccache.py", + "esphome/build_helpers/idedata.py", + "esphome/build_helpers/native.py", + "esphome/build_helpers/ninja.py", + "esphome/build_helpers/ninja_gen.py", + "esphome/build_helpers/tools_cache.py", + "esphome/framework_helpers.py", + "esphome/host/toolchain.py", + "esphome/platformio/library.py", } ) @@ -240,7 +251,7 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc. 4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed - - The dependency pins and the session init fixture affect every test + - The dependency pins and the host build backend affect every test Returns (run_all=False, [test_files...]) when: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 78e0b1a36c9..ce2e7f5bbc0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,6 @@ import re import shutil import signal import socket -import subprocess import sys import tempfile import time @@ -35,7 +34,7 @@ from esphome.helpers import ( write_file, write_file_if_changed, ) -from esphome.platformio.toolchain import get_idedata +from esphome.host.toolchain import get_elf_path from .const import ( API_CONNECTION_TIMEOUT, @@ -82,16 +81,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" -def _get_platformio_env(cache_dir: Path) -> dict[str, str]: - """Get environment variables for PlatformIO with shared cache.""" +def _get_build_env() -> dict[str, str]: + """Environment for an ``esphome compile`` subprocess.""" env = os.environ.copy() - env["PLATFORMIO_CORE_DIR"] = str(cache_dir) - env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - # 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" # Cap each compile's -j so several xdist workers do not each spawn a @@ -110,53 +102,41 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: return env +# Registry libraries (noise-c, lvgl, ...) download into /pio_components, +# and every integration test gets its own data dir. Share one download cache +# per xdist worker instead: the converter has no cross-process lock, so +# workers must never write the same dir, while a worker's later tests (and +# later runs) reuse what it fetched. +LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components" + + @pytest.fixture(scope="session") -def shared_platformio_cache() -> Generator[Path]: - """Initialize a shared PlatformIO cache for all integration tests.""" - # Use a dedicated directory for integration tests to avoid conflicts. - test_cache_dir = INTEGRATION_TESTS_ROOT - cache_dir = test_cache_dir / "platformio" +def shared_library_cache() -> Path: + """This worker's shared registry-library download cache.""" + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + # Keyed by checkout too: two sessions on one machine share worker ids + cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir - # Use a lock file in the home directory to ensure only one process initializes the cache - # This is needed when running with pytest-xdist - # The lock file must be in a directory that already exists to avoid race conditions - lock_file = Path.home() / ".esphome-integration-tests-init.lock" - # Always acquire the lock to ensure cache is ready before proceeding - with lock_file.open("w") as lock_fd: - fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - - # Check if the native platform is installed (the actual indicator of a populated cache) - native_platform = cache_dir / "platforms" / "native" - if not native_platform.exists(): - # Create the test cache directory if it doesn't exist - test_cache_dir.mkdir(exist_ok=True) - - with tempfile.TemporaryDirectory() as tmpdir: - # Use the cache_init fixture for initialization - init_dir = Path(tmpdir) - fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" - config_path = init_dir / "cache_init.yaml" - config_path.write_text( - fixture_path.read_text(encoding="utf-8"), encoding="utf-8" - ) - - # Run compilation to populate the cache - # We must succeed here to avoid race conditions where multiple - # tests try to populate the same cache directory simultaneously - env = _get_platformio_env(cache_dir) - - subprocess.run( - [sys.executable, "-m", "esphome", "compile", str(config_path)], - check=True, - cwd=init_dir, - env=env, - close_fds=False, - ) - - # Lock is held until here, ensuring cache is fully populated before any test proceeds - - yield cache_dir +def _link_library_cache(config_dir: Path, cache_dir: Path) -> None: + """Point a config dir's library download cache at the shared one + (blocking, run in executor).""" + data_dir = config_dir / ".esphome" + data_dir.mkdir(exist_ok=True) + link = data_dir / "pio_components" + if link.is_symlink(): + if link.resolve() == cache_dir.resolve(): + return + # A shared build dir was linked by another worker; writing through + # it would put two workers in one cache + link.unlink() + elif link.exists(): + # A real dir from a run predating the shared cache; nothing in it is + # worth more than a re-download + rmtree(link) + link.symlink_to(cache_dir, target_is_directory=True) @pytest.fixture(scope="module", autouse=True) @@ -238,18 +218,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s content = content.replace("api:", f"api:\n port: {unused_tcp_port}") # Add debug build flags for integration tests to enable assertions - if "esphome:" in content and "platformio_options:" not in content: - # Add platformio_options with debug flags after esphome: + if "esphome:" in content and "build_flags:" not in content: + # Add build_flags with debug flags after esphome: content = content.replace( "esphome:", "esphome:\n" " # Enable assertions for integration tests\n" - " platformio_options:\n" - " build_flags:\n" - ' - "-DDEBUG" # Enable assert() statements\n' - ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' - ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' - ' - "-g" # Add debug symbols', + " build_flags:\n" + ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' + ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' + ' - "-g" # Add debug symbols', ) # Replace external component path placeholder if present @@ -283,8 +262,8 @@ async def write_yaml_config( yield _write_config -# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale -# dirs for a fixture are pruned when its content hash changes. +# Deliberately not CI-cached; stale dirs for a fixture are pruned when its +# content hash changes. SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds" # In the dir name (not just the hash) so pruning stays inside this checkout @@ -470,8 +449,7 @@ def _resolve_compiled_binary(config_path: Path) -> Path: ) if config is None: raise RuntimeError(f"Failed to read config from {config_path}") - idedata = get_idedata(config) - binary_path = Path(idedata.firmware_elf_path) + binary_path = get_elf_path() if not binary_path.exists(): raise RuntimeError(f"Compiled binary not found at {binary_path}") return binary_path @@ -480,19 +458,22 @@ def _resolve_compiled_binary(config_path: Path) -> Path: @pytest_asyncio.fixture async def compile_esphome( integration_test_dir: Path, - shared_platformio_cache: Path, + shared_library_cache: Path, request: pytest.FixtureRequest, ) -> AsyncGenerator[CompileFunction]: """Compile an ESPHome configuration and return the binary path.""" async def _compile(config_path: Path) -> Path: - # Use the shared PlatformIO cache for faster compilation - # This avoids re-downloading dependencies for each test - env = _get_platformio_env(shared_platformio_cache) + env = _get_build_env() loop = asyncio.get_running_loop() name = _shared_yaml_name(request) if name is None: + # Share the library download cache so a test never re-fetches + # what an earlier one already pulled from the registry + await loop.run_in_executor( + None, _link_library_cache, integration_test_dir, shared_library_cache + ) await _run_esphome_compile(config_path, integration_test_dir, env) return await loop.run_in_executor( None, _resolve_compiled_binary, config_path @@ -568,6 +549,9 @@ async def compile_esphome( await loop.run_in_executor( None, write_file_if_changed, shared_config, content ) + await loop.run_in_executor( + None, _link_library_cache, shared_dir, shared_library_cache + ) await _run_esphome_compile(shared_config, shared_dir, env) if built is None or not built.exists(): built = await loop.run_in_executor( diff --git a/tests/integration/fixtures/api_action_timeout.yaml b/tests/integration/fixtures/api_action_timeout.yaml index 405d9d0e2b3..5dc6e1db050 100644 --- a/tests/integration/fixtures/api_action_timeout.yaml +++ b/tests/integration/fixtures/api_action_timeout.yaml @@ -1,9 +1,8 @@ esphome: name: api-action-timeout-test # Use a short timeout for testing (500ms instead of 30s) - platformio_options: - build_flags: - - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" + build_flags: + - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" host: diff --git a/tests/integration/fixtures/cache_init.yaml b/tests/integration/fixtures/cache_init.yaml deleted file mode 100644 index de208196cdf..00000000000 --- a/tests/integration/fixtures/cache_init.yaml +++ /dev/null @@ -1,10 +0,0 @@ -esphome: - name: cache-init - -host: - -api: - encryption: - key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" - -logger: diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index 2097b2fbf9c..05449afe86c 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1-hash-object-id-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1_hash_object_id; diff --git a/tests/integration/fixtures/fnv1a_hash.yaml b/tests/integration/fixtures/fnv1a_hash.yaml index d9c80601b88..3da0042e50e 100644 --- a/tests/integration/fixtures/fnv1a_hash.yaml +++ b/tests/integration/fixtures/fnv1a_hash.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1a-hash-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1a_hash; diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml index d11808c43a2..a7ba1c719a0 100644 --- a/tests/integration/fixtures/micros_to_millis.yaml +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -1,8 +1,7 @@ esphome: name: micros-to-millis-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::micros_to_millis; diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 8f82a121c61..ded7ef0a722 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -2143,10 +2143,7 @@ def test_no_orphan_integration_fixtures() -> None: fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve() fixtures = list(fixtures_dir.glob("*.yaml")) assert fixtures, f"no fixtures found under {fixtures_dir}" - # cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead - orphans = [ - f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping - ] + orphans = [f.stem for f in fixtures if f.stem not in mapping] assert not orphans, f"fixtures invisible to CI test selection: {orphans}" diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 4ad88347e2a..65a78c0253e 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -604,7 +604,7 @@ def test_write_project_libraries_and_variant( ) _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") - with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"): + with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"): content = _write_ninja( paths, libraries=[library, headers_only], ccache="/cc/ccache" ) @@ -616,6 +616,9 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # A library's own include dirs lead its compile lines + assert " own_includes = -I" in content + assert "$own_includes $cxxflags $flags" in content assert "-lalgobsec" in content # Library link flags reach the firmware link line; .cc compiles as C++ assert "-Wl,--wrap=malloc" in content diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py new file mode 100644 index 00000000000..d7fa73e897d --- /dev/null +++ b/tests/unit_tests/build_gen/test_host.py @@ -0,0 +1,368 @@ +"""Tests for esphome.build_gen.host (the host ninja generator).""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.arduino.library import ArduinoLibrary +from esphome.build_gen import host as build_gen +from esphome.build_helpers.ninja import escape as _e, quote_path as _q +from esphome.build_helpers.ninja_gen import Flag +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST +from esphome.core import CORE, EsphomeError, Library +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers + +COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",)) + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _make_src(tmp_path: Path, *names: str) -> Path: + src = tmp_path / "src" + for name in names: + path = src / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return src + + +def _render(ccache: str | None = None) -> tuple[bool, str]: + changed = build_gen.write_project(COMPILERS, ccache) + ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" + return changed, ninja.read_text() + + +@pytest.mark.parametrize( + ("entries", "compile_flags", "link_flags"), + [ + ( + ["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"], + [("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)], + [], + ), + (["-std=gnu++20"], [("-std=gnu++20",)], []), + ( + ["-lssl", "-L/opt/lib", "-Wl,--gc-sections"], + [], + [("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)], + ), + # A link-only flag travels with its argument + ( + ["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"], + [], + [ + ("-Xlinker", "--wrap=malloc"), + ("-z", "noexecstack"), + ("-framework", "Cocoa"), + ], + ), + # Both lines, as SCons routes unclassified flags + ( + ["-g -O2", "-fsanitize=address", "-pthread", "--coverage"], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + ), + # A flag with a path argument travels with it, glued on or not + ( + ["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"], + [("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)], + [], + ), + ], +) +def test_split_flags( + entries: list[str], compile_flags: list[Flag], link_flags: list[Flag] +) -> None: + flags = build_gen.parse_flags(entries, "esphome") + assert build_gen.split_flags(flags) == (compile_flags, link_flags) + + +@pytest.mark.parametrize( + "entries", + [ + # The flag is the last token of its entry + ["-g -include"], + # Entries are a set: the next entry is not this flag's argument + ["-include", "pre.h"], + ["-framework", "-pthread"], + # A path never starts with "-" + ["-include -pthread"], + ], +) +def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None: + with pytest.raises(EsphomeError, match="with no argument; write the flag"): + build_gen.parse_flags(entries, "esphome") + + +def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None: + assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [ + ("-Xlinker", "--wrap=malloc") + ] + + +def test_flag_lists_route_the_standard() -> None: + """cpp_standard wins for C++; C never sees a C++ standard.""" + CORE.build_flags = { + "-DUSE_HOST", + "-Iinc", + "-Wl,-x", + "-g", + "-lssl", + "-std=c++17", + "-std=gnu17", + } + CORE.cxx_build_flags = {"-Wno-volatile"} + cflags, cxxflags, link_flags = build_gen._flag_lists() + # The relative include is anchored at the build path + inc = f"-I{CORE.build_path / 'inc'}" + assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"] + assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"] + assert link_flags == ["-Wl,-x", "-g", "-lssl"] + + +def test_flag_lists_without_cpp_standard_keeps_user_std() -> None: + CORE.cpp_standard = None + CORE.build_flags = {"-std=c++17"} + cflags, cxxflags, _link = build_gen._flag_lists() + assert cflags == [] + assert cxxflags == ["-std=c++17"] + + +def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-g", "-DUSE_HOST"} + CORE.build_unflags = {"-g", "-Onope"} + with caplog.at_level(logging.WARNING): + cflags, cxxflags, link_flags = build_gen._flag_lists() + assert "-g" not in cflags + cxxflags + link_flags + assert "-DUSE_HOST" in cflags + assert "build_unflags entries matched no build flag: -Onope" in caplog.text + + +def test_resolve_host_libraries_without_libraries() -> None: + with patch("esphome.arduino.library.resolve_libraries") as resolve: + assert build_gen._resolve_host_libraries() == [] + resolve.assert_not_called() + + +def test_resolve_host_libraries_is_framework_less() -> None: + CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0")) + lib = ArduinoLibrary(name="lvgl") + with patch( + "esphome.arduino.library.resolve_libraries", return_value=[lib] + ) as resolve: + assert build_gen._resolve_host_libraries() == [lib] + resolve.assert_called_once_with( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + manifest_optional=True, + ) + + +def test_write_project_requires_generated_sources(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Generated source directory .* is missing"): + build_gen.write_project(COMPILERS, None) + _make_src(tmp_path, "esphome.h") + with pytest.raises(EsphomeError, match="No source files found"): + build_gen.write_project(COMPILERS, None) + + +def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h") + CORE.build_flags = {"-DUSE_HOST", "-g"} + changed, ninja = _render(ccache="/usr/bin/ccache") + assert changed is True + # A compiler override's arguments follow the program + assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja + assert f"cxx = {_q('/usr/bin/g++')}" in ninja + assert f"ccache = {_q('/usr/bin/ccache')}" in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja + ) + assert "build obj/src/x.S.o: aspp " in ninja + assert "build obj/src/y.s.o: asm " in ninja + assert "h.h" not in ninja + # __FILE__ stays relative to the build path, as it was under PlatformIO, + # whether the source arrives absolute or rewritten by ccache + sep = os.sep + maps = ( + f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} " + f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}" + ) + assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + # Assembly gets the defines and includes only + assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja + assert "linkflags = -g\n" in ninja + assert "libdirflags = \n" in ninja + assert "libflags = \n" in ninja + assert "rule ar" not in ninja + assert ( + f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o " + f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n" + ) in ninja + # Unchanged content reports no change so the compile DB can be reused + changed, _ = _render(ccache="/usr/bin/ccache") + assert changed is False + + +def test_write_project_without_ccache(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + _changed, ninja = _render() + assert "ccache = \n" in ninja + + +def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + lib_dir = tmp_path / "opt" / "lib" + # Forward slashes: build flags are lexed like a POSIX shell line, which + # reads a backslash as an escape + CORE.build_flags = { + f"-L{lib_dir.as_posix()}", + "-lcrypto", + "-Wl,-framework,Security", + } + _changed, ninja = _render() + assert "linkflags = -Wl,-framework,Security\n" in ninja + assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja + assert "libflags = -lcrypto\n" in ninja + + +def _libraries(tmp_path: Path) -> list[ArduinoLibrary]: + lib_dir = tmp_path / "libs" + for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"): + path = lib_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + archived = ArduinoLibrary( + name="foo", + sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"], + include_dirs=[lib_dir / "foo/src"], + flags=["-DFOO=1"], + link_dirs=[lib_dir / "foo/lib"], + link_libs=["bar"], + link_flags=["-Wl,--gc-sections"], + ) + direct = ArduinoLibrary( + name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False + ) + header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"]) + return [archived, direct, header_only] + + +def test_write_project_with_libraries( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + src = _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool, + patch.object(build_gen.sys, "platform", "linux"), + caplog.at_level(logging.DEBUG), + ): + _changed, ninja = _render() + find_tool.assert_called_once_with("AR", ("ar",)) + assert ( + f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp" + in ninja + ) + lib_dir = tmp_path / "libs" + # Every library's include dir joins the global include path + assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja + assert "linkflags = -Wl,--gc-sections\n" in ninja + assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja + assert "libflags = -lbar\n" in ninja + # Library sources compile with the library's own flags, rooted at their + # common parent; its own include dirs lead the line so another library's + # header of the same name cannot shadow them + own = f"-I{_q(lib_dir / 'foo/src')}" + assert "$own_includes $cxxflags $flags" in ninja + assert ( + f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert ( + f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja + # libArchive: false objects link directly; the archive is an order-only + # input wrapped in a group for GNU ld + assert ( + f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o " + "| libfoo.a\n" + " archives = -Wl,--start-group libfoo.a -Wl,--end-group\n" + ) in ninja + assert "Library hdr has no source files" in caplog.text + + +def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"), + patch.object(build_gen.sys, "platform", "darwin"), + ): + _changed, ninja = _render() + assert " archives = libfoo.a\n" in ninja + assert "--start-group" not in ninja + + +def test_build_unflags_remove_a_flag_with_its_argument( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the named pair goes; the same path under another flag stays.""" + CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"} + CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, link_flags = build_gen._flag_lists() + assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")] + assert link_flags == ["-g"] + assert "matched no build flag" not in caplog.text + + +def test_build_unflags_warn_when_nothing_matches( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.build_flags = {"-g"} + CORE.build_unflags = {"-isystem inc", "-DNOPE"} + with caplog.at_level(logging.WARNING): + build_gen._flag_lists() + assert "matched no build flag: -DNOPE, -isystem " in caplog.text + + +def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-Iinc", "-g"} + CORE.build_unflags = {"-Iinc"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, _link = build_gen._flag_lists() + assert cflags == ["-g"] + assert "matched no build flag" not in caplog.text diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index 0237db40811..468a97daab3 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -120,3 +120,48 @@ def test_parse_enable_env_spelling_tables( """cv.boolean's spelling tables plus the 1/0 env convention.""" monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw) assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected + + +def test_resolve_absolute_ccache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"): + resolved = ccache.resolve_absolute_ccache_path() + assert Path(resolved) == tmp_path / "bin" / "ccache" + with patch.object(ccache, "resolve_ccache_path", return_value=None): + assert ccache.resolve_absolute_ccache_path() is None + + +def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from esphome.core import CORE + + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache")) + monkeypatch.setenv("CCACHE_NOHASHDIR", "false") + for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"): + monkeypatch.delenv(key, raising=False) + # None means resolved and disabled + assert ccache.ccache_env(None, spec) == {} + env = ccache.ccache_env("/usr/bin/ccache", spec) + # User-set values are respected; the rest get defaults + assert "CCACHE_NOHASHDIR" not in env + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_defaults_env_escapes_a_dollar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A literal $ is doubled, since ccache expands $VAR in its settings.""" + from esphome.core import CORE + + CORE.build_path = tmp_path / "a$b" + for key in ("CCACHE_DIR", "CCACHE_BASEDIR"): + monkeypatch.delenv(key, raising=False) + env = ccache.ccache_defaults_env(tmp_path / "c$d") + assert env["CCACHE_DIR"].endswith("c$$d") + assert env["CCACHE_BASEDIR"].endswith("a$$b") diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index fcf9c67086c..9279ca52b2c 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -676,3 +676,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None: ) mock_build.assert_not_called() assert data["cached"] is True + + +def test_warn_if_idedata_missing_is_silent_on_success( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"}) + assert "idedata" not in caplog.text + + +def test_warn_if_idedata_missing_warns_on_none( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: None) + assert "No idedata was generated for this build" in caplog.text + + +@pytest.mark.parametrize( + "error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")] +) +def test_warn_if_idedata_missing_downgrades_failures( + caplog: pytest.LogCaptureFixture, error: Exception +) -> None: + """The firmware already built; a broken idedata must not fail the build.""" + + def broken() -> dict | None: + raise error + + with caplog.at_level(logging.DEBUG): + idedata.warn_if_idedata_missing(broken) + assert f"Could not generate idedata: {error}" in caplog.text + assert "Idedata failure detail" in caplog.text diff --git a/tests/unit_tests/build_helpers/test_native.py b/tests/unit_tests/build_helpers/test_native.py new file mode 100644 index 00000000000..57be9db26ac --- /dev/null +++ b/tests/unit_tests/build_helpers/test_native.py @@ -0,0 +1,34 @@ +"""Tests for esphome.build_helpers.native.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.build_helpers import native +from esphome.const import Toolchain +from esphome.core import CORE + + +def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: + """Options a native build drops are warned by name; consumed ones stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "lib_ignore": ["x"], + "board_build.filesystem": "littlefs", + } + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options({"lib_ignore"}) + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_warn_ignored_platformio_options_without_options( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.platformio_options = None + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options(()) + assert caplog.text == "" diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index 6f0bbda0b99..42782a1f058 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None: with patch.object(os, "name", "nt"): assert ninja_helper.shell_token("a b") == '"a b"' assert ninja_helper.shell_token("", force=True) == '""' + + +@pytest.mark.parametrize("char", ["|", "\n", "\r"]) +def test_escape_rejects_what_ninja_cannot_express(char: str) -> None: + with pytest.raises(EsphomeError, match="a ninja build file cannot express"): + ninja_helper.escape(f"src/a{char}b.cpp") + + +def test_escape_keeps_a_hash() -> None: + """A # is only a comment at the start of a ninja line.""" + assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp" + + +def test_absolute_tool_anchors_a_relative_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja" + + +def test_find_ninja_returns_an_absolute_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with ( + patch("shutil.which", return_value="bin/ninja"), + patch.object(ninja_helper, "_ninja_runs", return_value=True), + ): + assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja" + + +@pytest.fixture +def compdb_dir(tmp_path: Path) -> Path: + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "build.ninja").write_text("rule x\n") + return build_dir + + +def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None: + ninja = Path("ninja") + compdb = compdb_dir / "compile_commands.json" + stamp = compdb_dir / ".compile_commands.stamp" + with patch.object(ninja_helper, "write_compile_commands") as write: + # A rewritten manifest always regenerates + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True) + assert write.call_count == 1 + assert stamp.is_file() + # No compile DB yet + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + compdb.write_text("[]") + # Fresh stamp: nothing to do + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + # A manifest newer than the stamp (interrupted previous run) + os.utime(stamp, (1, 1)) + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 3 + # A missing stamp regenerates too + stamp.unlink() + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 4 + + +def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: + return MagicMock(returncode=rc, stdout=stdout, stderr=stderr) + + +def test_write_compile_commands_success(compdb_dir: Path) -> None: + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + with patch("subprocess.run", return_value=_completed(stdout=entries)) as run: + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"}) + assert (compdb_dir / "compile_commands.json").read_text() == entries + # Every compile rule, so preprocessed assembly reaches the database too + assert run.call_args.args[0] == [ + "ninja", + "-C", + str(compdb_dir), + "-t", + "compdb", + "c", + "cxx", + "aspp", + "asm", + ] + assert run.call_args.kwargs["env"] == {"A": "1"} + + +@pytest.mark.parametrize( + ("result", "message"), + [ + (_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"), + # A parse failure names its cause, not the rule-name story + (_completed(stdout="not json"), "unparsable compile database.*not json"), + (_completed(stdout="[]"), "empty compile database"), + ], +) +def test_write_compile_commands_failures_drop_stale_db( + compdb_dir: Path, result: MagicMock, message: str +) -> None: + compdb = compdb_dir / "compile_commands.json" + compdb.write_text("[stale]") + with ( + patch("subprocess.run", return_value=result), + pytest.raises(EsphomeError, match=message), + ): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert not compdb.exists() + + +def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db( + compdb_dir: Path, +) -> None: + """The idedata cache is keyed on the DB's mtime.""" + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + compdb = compdb_dir / "compile_commands.json" + compdb.write_text(entries) + os.utime(compdb, (1000, 1000)) + with patch("subprocess.run", return_value=_completed(stdout=entries)): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert compdb.stat().st_mtime == 1000 diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py new file mode 100644 index 00000000000..e7f9b96af2e --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -0,0 +1,57 @@ +"""Tests for esphome.build_helpers.ninja_gen.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from esphome.build_helpers import ninja_gen +from esphome.build_helpers.ninja_gen import Flag + + +@pytest.mark.parametrize( + ("flag", "anchored"), + [ + (("-Iinc",), ("-I{base}/inc",)), + (("-Llib",), ("-L{base}/lib",)), + (("-include", "pre.h"), ("-include", "{base}/pre.h")), + (("-isystem", "sys"), ("-isystem", "{base}/sys")), + # The glued spelling of a flag that takes a path + (("-isystemsys",), ("-isystem{base}/sys",)), + (("-includepre.h",), ("-include{base}/pre.h",)), + # An absolute operand is never changed + (("-I{base}/abs",), ("-I{base}/abs",)), + (("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")), + # Not a path + (("-DUSE_HOST",), ("-DUSE_HOST",)), + (("-lssl",), ("-lssl",)), + (("-framework", "Cocoa"), ("-framework", "Cocoa")), + (("-I",), ("-I",)), + ], +) +def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None: + """Relative operands resolve from the build path, as under PlatformIO.""" + + def fill(tokens: Flag) -> Flag: + return tuple( + str(Path(tok.replace("{base}", str(tmp_path)))) + if "{base}" in tok and not tok.startswith("-") + else tok.replace("{base}/", f"{tmp_path}{os.sep}") + for tok in tokens + ) + + assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored) + + +def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: + for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [ + tmp_path / "a.c", + tmp_path / "b.cpp", + tmp_path / "sub" / "c.S", + ] diff --git a/tests/unit_tests/components/host/__init__.py b/tests/unit_tests/components/host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/host/test_init.py b/tests/unit_tests/components/host/test_init.py new file mode 100644 index 00000000000..d7662e0b0a2 --- /dev/null +++ b/tests/unit_tests/components/host/test_init.py @@ -0,0 +1,86 @@ +"""Tests for the host platform's native-toolchain wiring.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import pytest + +from esphome.components import host +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _core_data() -> None: + CORE.data[KEY_CORE] = {} + + +def test_schema_resolves_the_host_toolchain() -> None: + """The host serves exactly one toolchain; it is picked without config.""" + config = host.CONFIG_SCHEMA({}) + assert CORE.toolchain is Toolchain.HOST + assert CORE.using_native_toolchain + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host" + assert "mac_address" in config + + +def test_schema_rejects_the_platformio_toolchain() -> None: + """There is no PlatformIO backend left to honor --toolchain platformio.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"): + host.CONFIG_SCHEMA({}) + + +def test_to_code_configures_the_native_build() -> None: + config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"}) + asyncio.run(host.to_code(config)) + assert "-DUSE_HOST" in CORE.build_flags + # The standard travels through cpp_standard, not a -std= build flag, + # so the generator can keep it off the C compile line + assert CORE.cpp_standard == "gnu++20" + assert not any(flag.startswith("-std=") for flag in CORE.build_flags) + assert not CORE.platformio_options + define_names = {define.name for define in CORE.defines} + assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= ( + define_names + ) + + +def test_run_compile_hook_claims_the_build() -> None: + config = {"esphome": {}} + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=0) as run, + ): + assert host.run_compile(object(), config) is True + run.assert_called_once_with(config, CORE.verbose) + + +def test_run_compile_hook_raises_on_failure() -> None: + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=2), + pytest.raises(EsphomeError, match="Host build failed"), + ): + host.run_compile(object(), {"esphome": {}}) + + +def test_run_compile_hook_refuses_windows() -> None: + """The host sources use POSIX interfaces; fail before building, by name.""" + with ( + patch.object(host.sys, "platform", "win32"), + patch("esphome.host.toolchain.run_compile") as run, + pytest.raises(EsphomeError, match="does not build on Windows"), + ): + host.run_compile(object(), {"esphome": {}}) + run.assert_not_called() diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 9f415344aea..ab11c41afb4 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -112,17 +112,6 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: assert env["CCACHE_DIR"] == "x" -def test_ccache_env(tmp_path: Path) -> None: - assert framework.ccache_env(None) == {} - with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - # User-set values are respected; the rest get defaults - assert "CCACHE_NOHASHDIR" not in env - assert env["CCACHE_DEPEND"] == "1" - assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve()) - assert env["CCACHE_DIR"].endswith("ccache") - - def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: """Calling the installer below the floor fails before any download.""" with pytest.raises(EsphomeError, match=">= 3.1.1"): @@ -148,18 +137,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"] -def test_ccache_env_accepts_a_preresolved_path() -> None: - """The caller resolves ccache once and threads it through; None means - resolved-and-disabled.""" - with patch.dict(os.environ, {}, clear=True): - assert framework.ccache_env(None) == {} - env = framework.ccache_env("/usr/bin/ccache") - assert env["CCACHE_DIR"].endswith("ccache") - - def test_toolchain_tool_layout(tmp_path: Path) -> None: """One owner for the bin/xtensa-lx106-elf- layout.""" tool = framework.toolchain_tool(tmp_path, "addr2line") assert tool.parent == tmp_path / "bin" assert tool.name.startswith("xtensa-lx106-elf-addr2line") assert (tool.suffix == ".exe") is (os.name == "nt") + + +def test_get_build_env_uses_the_arduino8266_ccache_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache") + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + # None means resolved and disabled + assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None) diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index a9c0546b4b1..62863744181 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_ESPHOME, KEY_CORE, KEY_FRAMEWORK_VERSION, + Toolchain, ) from esphome.core import CORE, EsphomeError @@ -80,7 +81,7 @@ def test_run_compile_build_failure(tmp_path: Path) -> None: patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=2) ) as mock_run, - patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2 cmd = mock_run.call_args[0][0] @@ -100,7 +101,7 @@ def test_run_compile_success(tmp_path: Path) -> None: "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ) as mock_run, - patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, patch.object(toolchain, "_print_size_summary") as mock_size, patch.object(toolchain, "get_idedata") as mock_idedata, ): @@ -116,39 +117,12 @@ def test_run_compile_success(tmp_path: Path) -> None: # cwd, not -C, so ninja prints no "Entering directory" banner assert "-C" not in cmd assert call[1]["cwd"] is not None - mock_compdb.assert_called_once() + # An unchanged manifest is passed on, so the shared refresh can skip + assert mock_compdb.call_args.args[3] is False mock_size.assert_called_once() mock_idedata.assert_called_once() -def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: - """An interrupted run can leave build.ninja newer than the compile DB; - mere existence must not skip regeneration.""" - build_dir = toolchain.get_build_dir() - build_dir.mkdir(parents=True, exist_ok=True) - (build_dir / "build.ninja").write_text("") - compdb = build_dir / "compile_commands.json" - compdb.write_text("[]") - os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2) - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock( - returncode=0, stdout="ninja: no work to do.\n", stderr="" - ), - ), - patch.object(toolchain, "_write_compile_commands") as mock_compdb, - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 - mock_compdb.assert_called_once() - - def test_run_compile_missing_artifact_fails( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -164,7 +138,7 @@ def test_run_compile_missing_artifact_fails( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary") as mock_size, patch.object(toolchain, "get_idedata"), ): @@ -188,7 +162,7 @@ def test_run_compile_warns_when_idedata_fails( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object(toolchain, "get_idedata", return_value=None), ): @@ -196,63 +170,6 @@ def test_run_compile_warns_when_idedata_fails( assert "No idedata was generated for this build" in caplog.text -def test_write_compile_commands(tmp_path: Path) -> None: - build_dir = tmp_path / "build" - build_dir.mkdir() - entries = '[{"file": "a.cpp", "command": "cc"}]\n' - with patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout=entries), - ): - toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) - assert (build_dir / "compile_commands.json").read_text() == entries - - -@pytest.mark.parametrize( - ("stdout", "match"), - [ - ("[]\n", "empty compile database"), - # A parse failure names its cause, not the rule-name story - ("not json", "unparsable compile database.*not json"), - ], -) -def test_write_compile_commands_bad_db_raises( - tmp_path: Path, stdout: str, match: str -) -> None: - """An empty or unparsable compile database fails the build with its - actual cause and drops any stale database.""" - build_dir = tmp_path / "build" - build_dir.mkdir() - (build_dir / "compile_commands.json").write_text("[stale]") - with ( - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout=stdout), - ), - pytest.raises(EsphomeError, match=match), - ): - toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) - assert not (build_dir / "compile_commands.json").exists() - - -def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None: - """A failed compdb run must not leave a stale database behind.""" - stale = tmp_path / "compile_commands.json" - stale.write_text("[]") - with ( - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=1, stderr="boom"), - ), - pytest.raises(EsphomeError, match="compile_commands"), - ): - toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {}) - assert not stale.exists() - - def test_parse_app_size(tmp_path: Path) -> None: ld = tmp_path / "eagle.flash.4m.ld" ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n") @@ -342,7 +259,9 @@ def test_get_idedata_delegates(tmp_path: Path) -> None: "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"cc_path": "x"}, ) as mock_load, - patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"), + patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache" + ), ): assert toolchain.get_idedata() == {"cc_path": "x"} compile_commands, elf, cache = mock_load.call_args[0] @@ -359,43 +278,12 @@ def test_get_idedata_no_ccache(tmp_path: Path) -> None: patch( "esphome.build_helpers.idedata.load_or_build_idedata", return_value={} ) as mock_load, - patch.object(toolchain, "resolve_ccache_path", return_value=None), + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), ): toolchain.get_idedata() assert mock_load.call_args.kwargs["launcher"] is None -def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None: - """An unchanged build.ninja means the compile DB is already current.""" - build_dir = toolchain.get_build_dir() - build_dir.mkdir(parents=True, exist_ok=True) - # write_project (stubbed below) always leaves a build.ninja behind - (build_dir / "build.ninja").write_text("# manifest") - - def run(regenerate_expected: bool) -> None: - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout="", stderr=""), - ), - patch.object(toolchain, "_write_compile_commands") as mock_compdb, - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 - assert mock_compdb.called == regenerate_expected - - # Missing compile DB: regenerated even though build.ninja is unchanged - run(regenerate_expected=True) - # Present compile DB + unchanged build.ninja: skipped - (build_dir / "compile_commands.json").write_text("[]") - run(regenerate_expected=False) - - def test_print_size_summary_unparsable_section( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -447,32 +335,6 @@ def test_print_size_summary_missing_section_skips_summary( assert "missing section(s) .bss" in caplog.text -def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: - """Component-added options the native build drops are warned by name; - the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, - flash_mode) stay quiet.""" - CORE.platformio_options = { - "board_build.ldscript": "eagle.flash.4m2m.ld", - "board_build.f_cpu": "160000000L", - "board_build.filesystem": "littlefs", - "board_build.flash_mode": "dio", - "build_src_flags": "-include throw_stubs.h", - "lib_ignore": ["Updater"], - "upload_speed": "460800", - } - toolchain._warn_ignored_platformio_options() - assert "platformio_options->board_build.filesystem is ignored" in caplog.text - assert "native 'arduino' toolchain" in caplog.text - assert "board_build.ldscript is ignored" not in caplog.text - assert "board_build.f_cpu is ignored" not in caplog.text - assert "lib_ignore" not in caplog.text - assert "build_src_flags" not in caplog.text - assert "flash_mode" not in caplog.text - # Component-added upload_speed never gets read under the native - # toolchain, so it must warn - assert "upload_speed" in caplog.text - - def test_run_compile_idedata_error_does_not_fail_build( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -486,7 +348,7 @@ def test_run_compile_idedata_error_does_not_fail_build( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object( toolchain, @@ -512,7 +374,7 @@ def test_run_compile_skipped_size_summary_names_consequence( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary", return_value=False), patch.object(toolchain, "get_idedata", return_value=None), ): @@ -541,8 +403,50 @@ def test_get_idedata_accepts_preresolved_ccache() -> None: "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"ok": True}, ) as mock_build, - patch.object(toolchain, "resolve_ccache_path") as mock_resolve, + patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve, ): assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True} mock_resolve.assert_not_called() assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache" + + +def test_run_compile_warns_about_dropped_platformio_options( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Component-added options the native build drops are warned by name; + the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, + flash_mode) stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "board_build.ldscript": "eagle.flash.4m2m.ld", + "board_build.f_cpu": "160000000L", + "board_build.filesystem": "littlefs", + "board_build.flash_mode": "dio", + "build_src_flags": "-include throw_stubs.h", + "lib_ignore": ["Updater"], + "upload_speed": "460800", + } + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=True), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "platformio_options->upload_speed is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert "board_build.f_cpu is ignored" not in caplog.text + assert "lib_ignore" not in caplog.text + assert "build_src_flags" not in caplog.text + assert "flash_mode" not in caplog.text + # A rewritten manifest is passed on, so the compile DB is regenerated + assert mock_compdb.call_args.args[3] is True diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 87de28cf327..77b30363ecc 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet( libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] assert "has no version to resolve" not in caplog.text + + +def test_resolve_libraries_without_framework_is_all_external() -> None: + """The host build has no core tree: nothing is bundled, the framework + check is skipped, and every name reaches the converter.""" + _add_library("Wire", None) + _add_library("lvgl/lvgl", "9.5.0") + + def fake_convert(libraries: list, backend: LibraryBackend) -> list: + assert backend.platform == "native" + assert backend.framework is None + assert backend.cache_key == "host" + assert backend.provides("Wire") is False + return [] + + with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock: + libs = component.resolve_libraries( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + ) + assert libs == [] + (libraries, _backend), _ = mock.call_args + assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"] diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0c96f8c8c99..94458109753 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -976,6 +976,14 @@ class TestEsphomeCore: target.toolchain = const.Toolchain.SDK_NRF assert target.using_native_toolchain is False + def test_using_toolchain_host(self, target): + """The host's only toolchain: the system compiler driven by ninja.""" + target.toolchain = const.Toolchain.HOST + assert target.using_toolchain_host is True + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_toolchain_host is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_host_toolchain.py b/tests/unit_tests/test_host_toolchain.py new file mode 100644 index 00000000000..4e4e4448016 --- /dev/null +++ b/tests/unit_tests/test_host_toolchain.py @@ -0,0 +1,329 @@ +"""Tests for esphome.host.toolchain (the native host build driver).""" + +from __future__ import annotations + +from collections.abc import Generator +import logging +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError +from esphome.host import toolchain +from esphome.host.toolchain import PROGRAM_NAME + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.config_path = tmp_path / "dev.yaml" + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.toolchain = Toolchain.HOST + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _abs(path: str) -> str: + """What the toolchain makes of a tool path (a drive is added on Windows).""" + return str(Path(path).absolute()) + + +def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CXX", "/opt/clang++") + with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get): + assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),) + + +def test_find_command_keeps_the_override_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``CC="gcc -m32"`` is a command, as make and CMake read it.""" + monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'") + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + "-m32", + "--sysroot=/opt/my sdk", + ) + + +@pytest.mark.parametrize( + ("override", "expected_args"), + [("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))], +) +def test_find_command_drops_a_ccache_prefix( + monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...] +) -> None: + """The build adds ccache itself; the compiler is the word after it.""" + monkeypatch.setenv("CC", override) + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + *expected_args, + ) + + +def test_find_command_accepts_a_compiler_named_ccache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Alone, the word is the program: ccache's compiler links work that way.""" + monkeypatch.setenv("CC", "ccache") + with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get): + assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),) + + +def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None: + """A broken override fails by name instead of silently using another compiler.""" + monkeypatch.setenv("CXX", "nope++") + with ( + patch("shutil.which", return_value=None), + pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"), + ): + toolchain.find_command("CXX", ("g++",)) + + +def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CC", " ") + with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == ( + _abs("/usr/bin/clang"), + ) + + +def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),) + + +def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + with ( + patch("shutil.which", return_value=None), + pytest.raises( + EsphomeError, + match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC", + ), + ): + toolchain.find_command("CC", ("gcc", "clang")) + + +def test_find_command_makes_a_relative_hit_absolute( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CC", "./toolchain/gcc") + with patch("shutil.which", return_value="./toolchain/gcc"): + (found,) = toolchain.find_command("CC", ("gcc",)) + assert Path(found) == Path("toolchain/gcc").absolute() + + +def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AR", "ar --plugin x") + with ( + patch("shutil.which", return_value="/usr/bin/ar"), + pytest.raises(EsphomeError, match="AR must name a program without arguments"), + ): + toolchain.find_tool("AR", ("ar",)) + + +def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("CC", "CXX"): + monkeypatch.delenv(var, raising=False) + table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_compilers() == toolchain.HostCompilers( + cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),) + ) + + +def test_build_paths(tmp_path: Path) -> None: + """The PlatformIO layout is kept: CORE.firmware_bin resolves the same file.""" + assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev" + assert toolchain.get_elf_path() == CORE.firmware_bin + + +def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("OBJDUMP", "READELF"): + monkeypatch.delenv(var, raising=False) + table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump")) + assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf")) + + +def test_get_build_env_merges_without_leaking( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + monkeypatch.setenv("KEEP_ME", "1") + env = toolchain.get_build_env("/usr/bin/ccache") + assert env["KEEP_ME"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert "CCACHE_DIR" not in os.environ + + +def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CCACHE_DIR", raising=False) + assert "CCACHE_DIR" not in toolchain.get_build_env(None) + + +@pytest.fixture +def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]: + """Stub everything run_compile resolves; the build dir holds a manifest.""" + build_dir = tmp_path / ".pioenvs" / "dev" + build_dir.mkdir(parents=True) + (build_dir / "build.ninja").write_text("rule x\n") + compilers = toolchain.HostCompilers(("gcc",), ("g++",)) + with ( + patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")), + patch.object(toolchain, "find_compilers", return_value=compilers), + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), + patch("esphome.build_gen.host.write_project", return_value=True) as project, + patch.object(toolchain, "refresh_compile_commands") as refresh, + patch.object( + toolchain, "_load_idedata", return_value={"cc_path": "gcc"} + ) as ide, + patch("subprocess.run") as run, + ): + yield SimpleNamespace( + build_dir=build_dir, + compilers=compilers, + write_project=project, + refresh=refresh, + idedata=ide, + run=run, + ) + + +def _completed(rc: int = 0) -> SimpleNamespace: + return SimpleNamespace(returncode=rc, stdout="", stderr="") + + +def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None: + elf = compile_env.build_dir / PROGRAM_NAME + + def build(cmd: list[str], **kwargs: object) -> SimpleNamespace: + elf.write_text("") + return _completed() + + compile_env.run.side_effect = build + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} + assert toolchain.run_compile(config, verbose=True) == 0 + + compile_env.write_project.assert_called_once_with(compile_env.compilers, None) + compile_env.refresh.assert_called_once() + assert compile_env.refresh.call_args.args[3] is True + compile_env.run.assert_called_once() + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + "-v", + "-j", + "4", + PROGRAM_NAME, + ] + assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir + compile_env.idedata.assert_called_once_with(None) + + +def test_run_compile_defaults(compile_env: SimpleNamespace) -> None: + """No verbosity and no process limit: just the ninja target.""" + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + PROGRAM_NAME, + ] + # The build inherits stdio and never captures: progress must stream + kwargs = compile_env.run.call_args.kwargs + assert kwargs["check"] is False + assert kwargs["close_fds"] is False + assert "capture_output" not in kwargs + + +def test_run_compile_warns_about_dropped_platformio_options( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"} + with caplog.at_level(logging.WARNING): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.f_cpu is ignored" in caplog.text + assert "native 'host' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + with patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache" + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + compile_env.write_project.assert_called_once_with( + compile_env.compilers, "/usr/bin/ccache" + ) + compile_env.idedata.assert_called_once_with("/usr/bin/ccache") + + +def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None: + compile_env.run.return_value = _completed(rc=3) + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3 + compile_env.idedata.assert_not_called() + + +def test_run_compile_missing_program_fails( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + """A green ninja run that produced no program fails by name.""" + compile_env.run.return_value = _completed() + with caplog.at_level(logging.ERROR): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1 + assert "Build produced no" in caplog.text + compile_env.idedata.assert_not_called() + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None: + with ( + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache), + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1} + ) as load, + ): + assert toolchain.get_idedata() == {"x": 1} + load.assert_called_once_with( + tmp_path / ".pioenvs" / "dev" / "compile_commands.json", + tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME, + CORE.relative_internal_path("idedata", "dev.json"), + launcher=ccache, + ) + + +@pytest.mark.parametrize("platform", ["darwin", "win32"]) +def test_check_analysis_supported_refuses_non_elf(platform: str) -> None: + with ( + patch.object(toolchain.sys, "platform", platform), + pytest.raises(EsphomeError, match=f"the host build on {platform}"), + ): + toolchain.check_analysis_supported() + + +def test_check_analysis_supported_accepts_linux() -> None: + with patch.object(toolchain.sys, "platform", "linux"): + toolchain.check_analysis_supported() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4f11be0cb8d..2ed61e5d26e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7805,52 +7805,206 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: assert second.index("a: 2") < second.index("z: 1") -def test_host_program_path_platformio_toolchain() -> None: - """Host + PlatformIO toolchain reads the memoized idedata path.""" - setup_core(platform=PLATFORM_HOST) - idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program") - with patch( - "esphome.platformio.toolchain.get_idedata", return_value=idedata - ) as mock_get: - assert main._host_program_path({}) == "/build/x/.pioenvs/x/program" - mock_get.assert_called_once_with({}) - - -def test_host_program_path_esp_idf_toolchain() -> None: - """Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF.""" - setup_core(platform=PLATFORM_HOST) - CORE.toolchain = Toolchain.ESP_IDF - with patch( - "esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf") - ): - assert main._host_program_path({}) == str(Path("/b/app.elf")) - - def test_command_compile_host_logs_program_path( + tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """command_compile on host logs the compiled program path.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), caplog.at_level(logging.INFO), ): assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0 - assert "Successfully compiled program to path '/b/program'" in caplog.text + assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text -def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None: +def test_command_run_host_executes_program( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: """command_run on host logs and executes the compiled program directly.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), patch.object(main, "run_external_process", return_value=0) as mock_run, caplog.at_level(logging.INFO), ): assert main.command_run(SimpleNamespace(), {}) == 0 - mock_run.assert_called_with("/b/program") - assert "Running program from path '/b/program'" in caplog.text + program = str(CORE.firmware_bin) + mock_run.assert_called_with(program) + assert f"Running program from path '{program}'" in caplog.text + + +def test_write_cpp_file_project_generation_follows_host_toolchain() -> None: + """Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project + here, and the other native builds generate theirs at compile time.""" + setup_core(platform=PLATFORM_HOST) + with ( + patch("esphome.writer.write_cpp"), + patch("esphome.build_gen.platformio.write_project") as mock_pio, + patch("esphome.build_gen.espidf.write_project") as mock_idf, + ): + CORE.toolchain = Toolchain.HOST + assert main.write_cpp_file() == 0 + mock_pio.assert_not_called() + mock_idf.assert_not_called() + CORE.toolchain = Toolchain.PLATFORMIO + assert main.write_cpp_file() == 0 + mock_pio.assert_called_once() + CORE.toolchain = Toolchain.ESP_IDF + assert main.write_cpp_file() == 0 + mock_idf.assert_called_once() + + +def test_compile_program_host_uses_the_platform_hook() -> None: + """The host component's run_compile claims the build.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.components.host.run_compile", return_value=True) as hook, + patch("esphome.__main__._check_and_emit_build_info") as build_info, + ): + assert compile_program(MagicMock(), {}) == 0 + hook.assert_called_once() + build_info.assert_called_once() + + +def test_compile_program_native_toolchain_needs_a_backend() -> None: + """A native toolchain no hook claims must not fall through to PlatformIO.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.platformio.toolchain.run_compile") as mock_pio, + # A platform package without a run_compile hook + patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}), + pytest.raises(EsphomeError, match="no platform backend claimed the build"), + ): + compile_program(MagicMock(), {}) + mock_pio.assert_not_called() + + +def test_native_backend_resolves_host() -> None: + from esphome.build_helpers.native import native_backend + from esphome.host import toolchain as host_toolchain + + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.PLATFORMIO + assert native_backend() is None + CORE.toolchain = Toolchain.HOST + assert native_backend() is host_toolchain + # A native toolchain without a backend for the platform must not degrade + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises( + EsphomeError, match="has no native build backend module for platform host" + ): + native_backend() + + +def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None: + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + data = {"cxx_path": "g++", "prog_path": "/build/program"} + with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get: + assert command_idedata(MagicMock(), CORE.config) == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]: + objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program")) + for tool in (objdump, readelf, elf): + tool.write_text("") + return objdump, readelf, elf + + +def test_command_analyze_memory_native_toolchain( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, +) -> None: + """A native backend supplies its own binutils and ELF, with no idedata.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + config = {CONF_ESPHOME: {CONF_NAME: "dev"}} + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + ): + assert command_analyze_memory(MockArgs(), config) == 0 + mock_memory_analyzer_cli.assert_called_once_with( + str(elf), str(objdump), str(readelf), set(), idedata=None + ) + mock_ram_strings_analyzer.assert_called_once_with( + str(elf), objdump_path=str(objdump), platform="host" + ) + assert "Mock Memory Report" in capfd.readouterr().out + + +def test_command_analyze_memory_native_missing_tool_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + readelf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{readelf} is missing; the toolchain install may be incomplete" in ( + caplog.text + ) + + +def test_command_analyze_memory_host_missing_elf_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + elf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{elf} is missing; compile the configuration first" in caplog.text + + +def test_command_analyze_memory_host_refuses_before_compiling( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + """A machine whose host program is not ELF fails before the compile.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.host.toolchain.sys.platform", "darwin"), + pytest.raises(EsphomeError, match="analyze-memory reads ELF files"), + ): + command_analyze_memory(MockArgs(), {}) + mock_write_cpp.assert_not_called() + mock_compile_program.assert_not_called() diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 512c883c374..dba8977f1a4 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -407,6 +407,28 @@ def test_convert_libraries_redownloads_when_manifest_missing( assert top[0].data["name"] == "A" +def test_convert_libraries_manifest_optional_uses_default_layout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A backend accepting manifest-less libraries (the host build, like + PlatformIO's native platform) gets a default manifest instead of a + re-download that would repeat every build.""" + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=True + ) + emitted: list[ConvertedLibrary] = [] + backend = _backend(emit=emitted.append) + backend.manifest_optional = True + + with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"): + top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend) + + assert calls == [False] + assert top[0].data == {"name": "esphome/A"} + assert emitted == top + assert "has no manifest; using PlatformIO's default layout" in caplog.text + + def test_convert_libraries_raises_when_manifest_missing_after_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1251,3 +1273,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet( _backend(), ) assert "has no version to resolve" not in caplog.text + + +def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None: + """symlink:// is PlatformIO's other spelling for a local library folder.""" + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "benchmark"})) + url = src.as_uri().replace("file://", "symlink://", 1) + + # Both the name=URL form and an explicit repository take the scheme + for library in ( + Library(f"benchmark={url}", None, None), + Library("benchmark", None, url), + ): + top = convert_libraries([library], _backend()) + assert isinstance(top[0].source, LocalSource) + assert top[0].source_path == src + + +def test_convert_libraries_incompatible_names_the_platform_without_a_framework( + setup_core: Path, +) -> None: + """The host has no framework; the error must not read 'compatible with None'.""" + src = setup_core / "lib_dev" + src.mkdir() + (src / "library.json").write_text( + json.dumps({"name": "Only32", "platforms": "espressif32"}) + ) + backend = _backend() + backend.platform = "native" + backend.framework = None + with pytest.raises(RuntimeError, match="Only32 is not compatible with native"): + convert_libraries([Library("Only32", None, src.as_uri())], backend) + + +@pytest.mark.parametrize( + ("url", "message"), + [ + ("symlink://host/lib", "Unsupported host in symlink:// library URL"), + ("symlink:lib_dev", "symlink:// library URL .* must be an absolute"), + ], +) +def test_convert_libraries_symlink_url_errors_name_the_scheme( + setup_core: Path, url: str, message: str +) -> None: + with pytest.raises(RuntimeError, match=message): + convert_libraries([Library("benchmark", None, url)], _backend()) From b8252f56eaf84db64b3b2025f8749efdea65d9a4 Mon Sep 17 00:00:00 2001 From: Panda <735591750@QQ.COM> Date: Mon, 28 Sep 2026 20:27:01 +0800 Subject: [PATCH 653/759] [platformio] Fix ccache path for non-root containers (#19807) Co-authored-by: J. Nick Koston --- docker/docker_entrypoint.sh | 8 +++- .../etc/s6-overlay/s6-rc.d/esphome/run | 8 +++- esphome/build_helpers/tools_cache.py | 3 ++ esphome/platformio/toolchain.py | 13 +++--- tests/unit_tests/test_platformio_toolchain.py | 42 +++++++++++++++++++ tests/unit_tests/test_writer.py | 22 ++++++++++ 6 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index c88a78f97e1..65d8fd61b55 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,10 +21,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native toolchain installs on the persistent cache root, not the -# container's ephemeral user cache dir (re-downloaded on every restart). +# Keep the native toolchain installs and compiler caches on the persistent +# cache root, not the container's user cache dir: it is lost on every +# restart, and not writable when the container runs as a non-root user. export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" +export ESPHOME_ARDUINO8266_PREFIX="$(dirname "${pio_cache_base}")/arduino8266" +export ESPHOME_HOST_PREFIX="$(dirname "${pio_cache_base}")/host" +export ESPHOME_PLATFORMIO_CCACHE_DIR="$(dirname "${pio_cache_base}")/platformio-ccache" # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index 20fada5f130..dd7f1bddc2e 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,10 +15,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" -# Keep the native toolchain installs on the persistent /data volume, not the -# container's ephemeral user cache dir (wiped on every add-on update/restart). +# Keep the native toolchain installs and compiler caches on the persistent +# /data volume, not the container's ephemeral user cache dir (wiped on every +# add-on update/restart). export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf +export ESPHOME_ARDUINO8266_PREFIX=/data/cache/arduino8266 +export ESPHOME_HOST_PREFIX=/data/cache/host +export ESPHOME_PLATFORMIO_CCACHE_DIR=/data/cache/platformio-ccache if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index 2dbbb2ffc22..e3d38e7a98e 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -35,9 +35,12 @@ SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") # The host backend installs nothing; the entry only holds its ccache dir HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host") +# PlatformIO installs into its own dirs; the entry is its ccache dir itself +PLATFORMIO_CCACHE = ("ESPHOME_PLATFORMIO_CCACHE_DIR", "platformio-ccache") TOOLS_CACHE_SPECS = ( IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE, HOST_TOOLS_CACHE, + PLATFORMIO_CCACHE, ) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 97b32420da6..2322b37dfc3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,9 +7,8 @@ import re import sys from typing import TYPE_CHECKING, Any -import platformdirs - from esphome.build_helpers.ccache import resolve_ccache_path +from esphome.build_helpers.tools_cache import PLATFORMIO_CCACHE, tools_cache_path from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError from esphome.framework_helpers import strip_win_long_path_prefix @@ -235,8 +234,9 @@ def _ccache_env() -> dict[str, str]: these values would hand it the wrong cache dir and a stale basedir. This mirrors ``_ccache_env()`` in ``esphome/espidf/framework.py``. The - cache lives under the machine-global ESPHome cache dir, so it is shared - across all projects and removed by ``esphome clean-all``. Unlike the + cache lives under the machine-global ESPHome cache dir, or where + ``ESPHOME_PLATFORMIO_CCACHE_DIR`` points (the containers set it to their + writable cache mount), and is removed by ``esphome clean-all``. Unlike the ESP-IDF path, ``CCACHE_DEPEND`` is not set: SCons compiles don't emit the depfiles depend mode needs, so ccache's default preprocessor mode is used. @@ -264,10 +264,7 @@ def _ccache_env() -> dict[str, str]: ) env["CCACHE_BASEDIR"] = str(Path(CORE.build_path).resolve()) defaults = { - "CCACHE_DIR": str( - Path(platformdirs.user_cache_dir("esphome", appauthor=False)) - / "platformio-ccache" - ), + "CCACHE_DIR": str(tools_cache_path(*PLATFORMIO_CCACHE)), "CCACHE_NOHASHDIR": "true", } env.update({k: v for k, v in defaults.items() if k not in os.environ}) diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index fb99ea92084..6138d745248 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -447,6 +447,47 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: assert "ESPHOME_CCACHE_ENABLE" not in os.environ +def test_ccache_env_uses_cache_dir_override(setup_core: Path, tmp_path: Path) -> None: + """The containers point ccache at their writable cache mount.""" + CORE.build_path = setup_core / "build" / "test" + ccache_dir = tmp_path / "cache" / "platformio-ccache" + + with ( + patch.dict( + os.environ, + {"HOME": "/", "ESPHOME_PLATFORMIO_CCACHE_DIR": str(ccache_dir)}, + clear=True, + ), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), + ): + env = toolchain._ccache_env() + + assert env["CCACHE_DIR"] == str(ccache_dir.resolve()) + + +def test_ccache_env_ignores_platformio_cache_dir( + setup_core: Path, tmp_path: Path +) -> None: + """PLATFORMIO_CACHE_DIR does not move ccache; only the override does.""" + CORE.build_path = setup_core / "build" / "test" + cache_root = tmp_path / "user-cache" + + with ( + patch.dict( + os.environ, + {"PLATFORMIO_CACHE_DIR": str(tmp_path / "platformio" / "cache")}, + clear=True, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), + ): + env = toolchain._ccache_env() + + assert env["CCACHE_DIR"] == str((cache_root / "platformio-ccache").resolve()) + + @pytest.mark.parametrize( ("env_vars", "expect_warning"), [ @@ -582,6 +623,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( user_env = { "CCACHE_DIR": "/custom/cache", "CCACHE_BASEDIR": "/stale/other-device", + "ESPHOME_PLATFORMIO_CCACHE_DIR": "/mounted/platformio-ccache", } CORE.build_path = setup_core / "build" / "test" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 3dcc4b12b88..7fd03062a27 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1085,6 +1085,28 @@ def test_clean_all_removes_global_arduino8266_install( assert str(arduino8266_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_platformio_ccache( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the PlatformIO ccache dir the containers relocate.""" + ccache_dir = tmp_path / "platformio-ccache" + (ccache_dir / "0").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_PLATFORMIO_CCACHE_DIR", str(ccache_dir)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not ccache_dir.exists() + assert str(ccache_dir.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_removes_default_cache_root( mock_core: MagicMock, From 4c2356485120607dab192086cdd6eded77e3a802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 14:41:34 +0200 Subject: [PATCH 654/759] [hlw8032] Use the sub_* helpers for optional child entities (#19835) --- esphome/components/hlw8032/sensor.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index 7b069d85d0f..f2a8ffb2161 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -79,20 +79,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if apparent_power_config := config.get(CONF_APPARENT_POWER): - sens = await sensor.new_sensor(apparent_power_config) - cg.add(var.set_apparent_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) From 9f7d7284a2332e776d2d2f8422e193459a19f7be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 14:42:26 +0200 Subject: [PATCH 655/759] [pzemdc] Use the sub_* helpers for optional child entities (#19834) --- esphome/components/pzemdc/sensor.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 8ec88d69095..b3f59720ad3 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -85,19 +85,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) From b4b400063325b5f50bf6d81b48ebc2ec9c1f7e4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 14:44:07 +0200 Subject: [PATCH 656/759] [wireguard] Use the sub_* helpers for optional child entities (#19833) --- esphome/components/wireguard/binary_sensor.py | 12 ++++-------- esphome/components/wireguard/sensor.py | 7 +++---- esphome/components/wireguard/text_sensor.py | 7 +++---- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/esphome/components/wireguard/binary_sensor.py b/esphome/components/wireguard/binary_sensor.py index 2ba59d4c39d..a1fd366ccf6 100644 --- a/esphome/components/wireguard/binary_sensor.py +++ b/esphome/components/wireguard/binary_sensor.py @@ -24,12 +24,8 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if status_config := config.get(CONF_STATUS): - sens = await binary_sensor.new_binary_sensor(status_config) - cg.add(parent.set_status_sensor(sens)) - - if enabled_config := config.get(CONF_ENABLED): - sens = await binary_sensor.new_binary_sensor(enabled_config) - cg.add(parent.set_enabled_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_STATUS, hub.set_status_sensor) + await binary_sensors(CONF_ENABLED, hub.set_enabled_sensor) diff --git a/esphome/components/wireguard/sensor.py b/esphome/components/wireguard/sensor.py index 4c9d601eee2..49146025afa 100644 --- a/esphome/components/wireguard/sensor.py +++ b/esphome/components/wireguard/sensor.py @@ -19,8 +19,7 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if latest_handshake_config := config.get(CONF_LATEST_HANDSHAKE): - sens = await sensor.new_sensor(latest_handshake_config) - cg.add(parent.set_handshake_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_LATEST_HANDSHAKE, hub.set_handshake_sensor) diff --git a/esphome/components/wireguard/text_sensor.py b/esphome/components/wireguard/text_sensor.py index e48678f5bf4..7beb4b08f06 100644 --- a/esphome/components/wireguard/text_sensor.py +++ b/esphome/components/wireguard/text_sensor.py @@ -16,8 +16,7 @@ CONFIG_SCHEMA = { async def to_code(config): - parent = await cg.get_variable(config[CONF_WIREGUARD_ID]) + hub = await cg.get_variable(config[CONF_WIREGUARD_ID]) - if address_config := config.get(CONF_ADDRESS): - sens = await text_sensor.new_text_sensor(address_config) - cg.add(parent.set_address_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ADDRESS, hub.set_address_sensor) From 21d9aff769015b9d9a37a7631fa0a3f63e00489a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 14:44:20 +0200 Subject: [PATCH 657/759] [qmc5883l] Use the sub_* helpers for optional child entities (#19832) --- esphome/components/qmc5883l/sensor.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index e0186be1638..06091c4f1ca 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -150,21 +150,12 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(config[CONF_DATA_RATE])) cg.add(var.set_range(config[CONF_RANGE])) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) if CONF_DRDY_PIN in config: pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) cg.add(var.set_drdy_pin(pin)) From b56198f24236fe000cb722c6a0b63998c1937346 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 14:44:32 +0200 Subject: [PATCH 658/759] [tsl2591] Use the sub_* helpers for optional child entities (#19831) --- esphome/components/tsl2591/sensor.py | 30 ++++++---------------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index fa202b0cea3..ee6ebaeb75c 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -140,30 +140,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_FULL_SPECTRUM in config: - conf = config[CONF_FULL_SPECTRUM] - sens = await sensor.new_sensor(conf) - cg.add(var.set_full_spectrum_sensor(sens)) - - if CONF_INFRARED in config: - conf = config[CONF_INFRARED] - sens = await sensor.new_sensor(conf) - cg.add(var.set_infrared_sensor(sens)) - - if CONF_VISIBLE in config: - conf = config[CONF_VISIBLE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_visible_sensor(sens)) - - if CONF_CALCULATED_LUX in config: - conf = config[CONF_CALCULATED_LUX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_calculated_lux_sensor(sens)) - - if CONF_ACTUAL_GAIN in config: - conf = config[CONF_ACTUAL_GAIN] - sens = await sensor.new_sensor(conf) - cg.add(var.set_actual_gain_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_VISIBLE, var.set_visible_sensor) + await sensors(CONF_CALCULATED_LUX, var.set_calculated_lux_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) From 3fb4a9cb3a728f5e99722eef5733ab1d06d2981f Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:34:33 +0000 Subject: [PATCH 659/759] Bump bundled esphome-device-builder to 1.17.1 (#19838) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 408e9e21559..079ebef1947 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.17.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.17.1 RUN \ platformio settings set enable_telemetry No \ From b4f2334b99c9b850cb8968421104e7ea70be5306 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 28 Sep 2026 16:57:00 +0200 Subject: [PATCH 660/759] [nrf52] Use full newlib when the logger is enabled, disable unused serial and printk (#19766) Co-authored-by: J. Nick Koston --- esphome/components/ble_nus/__init__.py | 1 + esphome/components/debug/__init__.py | 1 + esphome/components/logger/__init__.py | 1 + esphome/components/logger/logger_zephyr.cpp | 2 ++ esphome/components/nrf52/__init__.py | 25 +++++++++++++------ esphome/components/zephyr/__init__.py | 1 + .../components/zephyr_mcumgr/ota/__init__.py | 1 + .../components/logger/test.nrf52-mcumgr.yaml | 1 + tests/unit_tests/test_nrf52_config.py | 12 +++++++++ 9 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_config.py diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 55a2f9bd188..40983c4dbb0 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -62,6 +62,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT_NUS", True) + zephyr_add_prj_conf("RING_BUFFER", True) request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU expose_log = config[CONF_TYPE] == CONF_LOGS cg.add(var.set_expose_log(expose_log)) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index e4692dfa9aa..898d7d6c0e7 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -60,6 +60,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("LOG_BLOCK_IN_THREAD", True) zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096) zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True) + zephyr_add_prj_conf("PRINTK", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add_define("USE_DEBUG") diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index eb1f34565a2..d6b9bca38ba 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -504,6 +504,7 @@ async def _late_logger_init(config: ConfigType) -> None: zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False) zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True) if has_serial_logging: + zephyr_add_prj_conf("SERIAL", True) if config[CONF_HARDWARE_UART] == UART0: zephyr_add_overlay("""&uart0 { status = "okay";};""") if config[CONF_HARDWARE_UART] == UART1: diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index b7884b702ba..69ef6167da5 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -54,6 +54,7 @@ void Logger::cdc_loop_() { #endif void Logger::pre_setup() { +#ifdef CONFIG_SERIAL if (this->baud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { @@ -87,6 +88,7 @@ void Logger::pre_setup() { #endif } } +#endif global_logger = this; ESP_LOGI(TAG, "Log initialized"); #ifdef USE_LOGGER_EARLY_MESSAGE diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index b32d4f075ea..0d1cfd6c1e0 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -175,6 +175,8 @@ def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() bootloaders: list[str] = [] + if CONF_BOARD not in config: + raise cv.Invalid("'board' is a required option for [nrf52].") board = config[CONF_BOARD] if board in BOARDS_ZEPHYR and KEY_BOOTLOADER in BOARDS_ZEPHYR[board]: @@ -249,7 +251,7 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, - cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, + cv.Optional(CONF_LIBC_NANO): cv.boolean, cv.Optional( CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY ): cv.Schema( @@ -295,7 +297,7 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] - if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + if conf.get(CONF_LIBC_NANO, False) and "logger" in CORE.loaded_integrations: _LOGGER.warning( "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " "such as %%zu are not supported and will print incorrectly. " @@ -401,7 +403,10 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_OTA_ROLLBACK") zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) - zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) + zephyr_add_prj_conf( + "NEWLIB_LIBC_NANO", + conf.get(CONF_LIBC_NANO, "logger" not in CORE.loaded_integrations), + ) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) @@ -412,9 +417,6 @@ async def to_code(config: ConfigType) -> None: # watchdog zephyr_add_prj_conf("WATCHDOG", True) zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False) - # disable console - zephyr_add_prj_conf("UART_CONSOLE", False) - zephyr_add_prj_conf("CONSOLE", False, False) # use NFC pins as GPIO if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True) @@ -428,9 +430,17 @@ async def to_code(config: ConfigType) -> None: ) zephyr_add_prj_conf("REBOOT", True) - # some boards enable USB by default. + # some boards enable USB and UART by default. # disable it to prevent extra current consumption. zephyr_add_prj_conf("USB_DEVICE_STACK", False, False) + zephyr_add_prj_conf("SERIAL", False, False) + + # disable stuff to make image smaller by default + zephyr_add_prj_conf("NCS_BOOT_BANNER", False, False) + zephyr_add_prj_conf("BOOT_BANNER", False, False) + zephyr_add_prj_conf("PRINTK", False, False) + zephyr_add_prj_conf("CONSOLE", False, False) + zephyr_add_prj_conf("UART_CONSOLE", False) @coroutine_with_priority(CoroPriority.DIAGNOSTICS) @@ -445,6 +455,7 @@ async def _dfu_to_code(dfu_config): zephyr_add_prj_conf("USB_DEVICE_STACK", True) zephyr_add_prj_conf("USB_CDC_ACM", True) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + zephyr_add_prj_conf("SERIAL", True) await cg.register_component(var, dfu_config) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2bcdf0380c7..49654736cc2 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -210,6 +210,7 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) zephyr_add_prj_conf("USB_DEVICE_STACK", True) zephyr_add_prj_conf("USB_CDC_ACM", True) + zephyr_add_prj_conf("SERIAL", True) # prevent device to go to susspend, without this communication stop working in python # there should be a way to solve it zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 9fc02d87080..9aa78db8b1a 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -147,6 +147,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: + zephyr_add_prj_conf("SERIAL", True) uart = UARTS[transport[CONF_HARDWARE_UART]] uart_name = uart[0] cdc_id = uart[1] diff --git a/tests/components/logger/test.nrf52-mcumgr.yaml b/tests/components/logger/test.nrf52-mcumgr.yaml index 70b485daac2..e37a18a519d 100644 --- a/tests/components/logger/test.nrf52-mcumgr.yaml +++ b/tests/components/logger/test.nrf52-mcumgr.yaml @@ -5,3 +5,4 @@ esphome: logger: level: DEBUG + hardware_uart: UART0 diff --git a/tests/unit_tests/test_nrf52_config.py b/tests/unit_tests/test_nrf52_config.py new file mode 100644 index 00000000000..4d9d281ebfe --- /dev/null +++ b/tests/unit_tests/test_nrf52_config.py @@ -0,0 +1,12 @@ +"""Tests for the nRF52 configuration validation.""" + +import pytest + +from esphome.components.nrf52 import _detect_bootloader +import esphome.config_validation as cv + + +def test_detect_bootloader_reports_a_missing_board() -> None: + """The bootloader check runs before the schema, so it reports the missing key.""" + with pytest.raises(cv.Invalid, match="'board' is a required option"): + _detect_bootloader({}) From 45b9c8b94cfbfcdba17a8ded5c6ae453b0d2887b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 28 Sep 2026 17:37:44 +0200 Subject: [PATCH 661/759] [zephyr_mcumgr] add LED which is controled by bootloader (#16322) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/zephyr_mcumgr/ota/__init__.py | 36 +++++++++++++++++++ tests/components/ota/test.nrf52-adafruit.yaml | 4 +++ 2 files changed, 40 insertions(+) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 9aa78db8b1a..f5ab04809e4 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,3 +1,4 @@ +from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import BOOTLOADER_CONFIG from esphome.components.nrf52.framework import include_west_project @@ -19,6 +20,10 @@ import esphome.config_validation as cv from esphome.const import ( CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, + CONF_NUMBER, + CONF_PIN, + CONF_STATUS, KEY_CORE, KEY_FRAMEWORK_VERSION, Framework, @@ -67,6 +72,11 @@ CONFIG_SCHEMA = cv.All( ): cv.one_of(*UARTS, upper=True), } ), + cv.Optional(CONF_STATUS): cv.Schema( + { + cv.Required(CONF_PIN): pins.gpio_output_pin_schema, + } + ), } ) .extend(BASE_OTA_SCHEMA) @@ -165,6 +175,32 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + if CONF_STATUS in config: + pin_conf = config[CONF_STATUS][CONF_PIN] + pin_num = pin_conf[CONF_NUMBER] + port = pin_num // 32 + pin_in_port = pin_num % 32 + active_flag = ( + "GPIO_ACTIVE_LOW" if pin_conf[CONF_INVERTED] else "GPIO_ACTIVE_HIGH" + ) + zephyr_add_prj_conf("MCUBOOT_INDICATION_LED", True, image="mcuboot") + zephyr_add_overlay( + f""" + #include + / {{ + mcuboot_leds {{ + compatible = "gpio-leds"; + mcuboot_led0: mcuboot_led0 {{ + gpios = <&gpio{port} {pin_in_port} {active_flag}>; + }}; + }}; + aliases {{ + mcuboot-led0 = &mcuboot_led0; + }}; + }}; + """, + image="mcuboot", + ) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver >= cv.Version(2, 9, 2): zephyr_data()[KEY_SYSBUILD] = True diff --git a/tests/components/ota/test.nrf52-adafruit.yaml b/tests/components/ota/test.nrf52-adafruit.yaml index e8ac96f051e..d0a44d2ff7b 100644 --- a/tests/components/ota/test.nrf52-adafruit.yaml +++ b/tests/components/ota/test.nrf52-adafruit.yaml @@ -2,3 +2,7 @@ zephyr_ble_server: ota: - platform: zephyr_mcumgr + status: + pin: + number: P0.15 + inverted: true From 787ee418b033b50251d61210b2fe566ef7c63129 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 18:28:03 +0200 Subject: [PATCH 662/759] [esp32] Precompile the core headers for ESP-IDF source compiles (#19820) --- .github/workflows/ci-docker.yml | 1 + esphome/build_gen/espidf.py | 47 +++++++++ esphome/build_helpers/pch.py | 94 +++++++++++++++++ esphome/core/pch_prefix.h | 5 + esphome/espidf/framework.py | 2 + esphome/espidf/toolchain.py | 4 + esphome/writer.py | 2 + tests/unit_tests/build_gen/test_espidf.py | 75 ++++++++++++++ tests/unit_tests/build_helpers/test_pch.py | 111 +++++++++++++++++++++ tests/unit_tests/conftest.py | 7 ++ tests/unit_tests/test_espidf_framework.py | 3 + tests/unit_tests/test_espidf_toolchain.py | 28 ++++++ 12 files changed, 379 insertions(+) create mode 100644 esphome/build_helpers/pch.py create mode 100644 esphome/core/pch_prefix.h create mode 100644 tests/unit_tests/build_helpers/test_pch.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5ef51be02cc..bf099d4ebf7 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -19,6 +19,7 @@ on: - "esphome/core/**" - "esphome/writer.py" - "esphome/build_gen/**" + - "esphome/build_helpers/**" - "esphome/espidf/**" - "esphome/platformio/**" - "esphome/components/bk72xx/**" diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 7689fc93b0e..766c945467f 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -4,6 +4,7 @@ import json import logging from pathlib import Path +from esphome.build_helpers import pch from esphome.components.esp32 import ( get_esp32_variant, get_excluded_builtin_components, @@ -282,9 +283,55 @@ idf_component_register( target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} ) +{_pch_cmake_block()}""" + + +# Where CMake puts the .gch of the src component; ccache reads the checksum +# next to it in place of the .gch +_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum" + + +def _pch_cmake_block() -> str: + """The CMake block that precompiles the core headers for the C++ sources + of the src component; empty when disabled.""" + if not pch.pch_enabled(): + return "" + headers = "\n".join( + f' "$<$:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"' + for header in pch.PCH_DEFAULT_HEADERS + ) + return f""" +# ESPHome precompiled header +target_precompile_headers(${{COMPONENT_LIB}} PRIVATE +{headers} +) """ +def _read_if_exists(path: Path) -> str: + return path.read_text(encoding="utf-8") if path.is_file() else "" + + +def write_pch_checksum() -> None: + """Write the checksum ccache uses in place of the .gch: the core headers, + the framework version, the sdkconfig and the managed component versions.""" + if not pch.pch_enabled(): + return + pch.log_pch_in_use() + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(idf_version()), + _read_if_exists(CORE.relative_build_path(f"sdkconfig.{CORE.name}")), + _read_if_exists(CORE.relative_build_path("dependencies.lock")), + ), + ) + path = CORE.relative_build_path(_PCH_SUM_PATH) + path.parent.mkdir(parents=True, exist_ok=True) + write_file_if_changed(path, checksum + "\n") + + def write_project( minimal: bool = False, builtin_components: list[str] | None = None ) -> None: diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py new file mode 100644 index 00000000000..d93c186c7c4 --- /dev/null +++ b/esphome/build_helpers/pch.py @@ -0,0 +1,94 @@ +"""Shared precompiled header policy for the build backends.""" + +from __future__ import annotations + +from collections.abc import Iterable +import hashlib +import logging +import os +from pathlib import Path +import posixpath +import re + +from esphome.build_helpers.ccache import parse_enable_env + +_LOGGER = logging.getLogger(__name__) + +# The core headers every backend precompiles +PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) + +# What ccache needs to cache compiles that load a .gch +_CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros") + +# Both include forms: an angle include resolving under src/ enters the digest +_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) + + +def pch_enabled() -> bool: + """Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out.""" + return parse_enable_env("ESPHOME_PCH_ENABLE") is not False + + +def ccache_pch_env() -> dict[str, str]: + """What ccache needs to cache compiles that load a .gch, added to what + the user already set.""" + if not pch_enabled(): + return {} + sloppiness = [ + item.strip() + for item in os.environ.get("CCACHE_SLOPPINESS", "").split(",") + if item.strip() + ] + sloppiness += [item for item in _CCACHE_PCH_SLOPPINESS if item not in sloppiness] + env = {"CCACHE_SLOPPINESS": ",".join(sloppiness)} + if "CCACHE_PCH_EXTSUM" not in os.environ: + env["CCACHE_PCH_EXTSUM"] = "true" + return env + + +def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]: + """Include closure of ``roots``: src-relative name -> contents. + + Resolution mirrors the compiler (includer's dir, then src root). No + #ifdef evaluation: including too much is the safe direction. Headers + outside ``src_dir`` are covered by the version strings of the caller. + """ + seen: dict[str, bytes] = {} + stack: list[tuple[str, str]] = [(name, "") for name in roots] + while stack: + name, from_dir = stack.pop() + for candidate in (f"{from_dir}/{name}" if from_dir else name, name): + rel = posixpath.normpath(candidate) + if not rel.startswith("..") and (src_dir / rel).is_file(): + break + else: + continue + if rel in seen: + continue + data = seen[rel] = (src_dir / rel).read_bytes() + parent = posixpath.dirname(rel) + stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data)) + return seen + + +def pch_checksum( + src_dir: Path, include_headers: Iterable[str], extra: Iterable[str] +) -> str: + """Digest of the prefix header's include closure plus ``extra``.""" + digest = hashlib.sha256() + closure = _include_closure(src_dir, include_headers) + for name in sorted(closure): + digest.update(name.encode()) + digest.update(closure[name]) + digest.update(b"\0") + for item in extra: + digest.update(item.encode()) + digest.update(b"\0") + return digest.hexdigest() + + +_DISABLE_HINT = " (set ESPHOME_PCH_ENABLE=0 to disable)" + + +def log_pch_in_use() -> None: + _LOGGER.info("Compiling with a precompiled header%s", _DISABLE_HINT) diff --git a/esphome/core/pch_prefix.h b/esphome/core/pch_prefix.h new file mode 100644 index 00000000000..fbb4eaf79f7 --- /dev/null +++ b/esphome/core/pch_prefix.h @@ -0,0 +1,5 @@ +#pragma once +// The core headers the precompiled header holds +#include "esphome/core/defines.h" +#include "esphome/core/application.h" +#include "esphome/core/automation.h" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 9373b5f5691..8885da05cca 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -16,6 +16,7 @@ from esphome.build_helpers.ccache import ( parse_enable_env, resolve_ccache_path, ) +from esphome.build_helpers.pch import ccache_pch_env from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path from esphome.core import Version from esphome.framework_helpers import ( @@ -1240,6 +1241,7 @@ def _ccache_env() -> dict[str, str]: return {"IDF_CCACHE_ENABLE": "0"} env = ccache_defaults_env(get_idf_tools_path() / "ccache") + env.update(ccache_pch_env()) # Exactly one canonical spelling ever reaches idf.py, whatever the # accepted input spelling was ("enable", "yes", ...) env["IDF_CCACHE_ENABLE"] = "1" diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index f695bdb7ab4..f8575234f88 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -528,6 +528,10 @@ def run_compile(config, verbose: bool) -> int: return result.returncode _patch_memory_segments() + from esphome.build_gen.espidf import write_pch_checksum + + write_pch_checksum() + # Build args = [] diff --git a/esphome/writer.py b/esphome/writer.py index bddb6a0f5f8..bdd60af1dc9 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -220,6 +220,8 @@ ESPHOME_H_EXCLUDE = { Path("esphome/core/controller_dispatch.h"), # moved to components/ring_buffer/, removed in 2026.11.0 Path("esphome/core/ring_buffer.h"), + # build machinery, not user API + Path("esphome/core/pch_prefix.h"), } ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 2848d7202df..ec8e2f3a364 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -500,3 +500,78 @@ def test_get_component_cmakelists_no_compile_features() -> None: content = get_component_cmakelists() assert "target_compile_features" not in content + + +def _make_pch_project(tmp_path: Path) -> Path: + """A build path with the core headers, an sdkconfig and a lock file.""" + from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS + + CORE.build_path = tmp_path + for header in PCH_DEFAULT_HEADERS: + path = tmp_path / "src" / header + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('#include "esphome/core/defines.h"\n') + (tmp_path / "src" / "esphome" / "core" / "defines.h").write_text("#define M 1\n") + (tmp_path / "sdkconfig.test").write_text("CONFIG_X=y\n") + (tmp_path / "dependencies.lock").write_text("espressif/mdns: 1.12.0\n") + return tmp_path + + +def _pch_checksum() -> str: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + with patch.object(CORE, "name", "test"): + write_pch_checksum() + return CORE.relative_build_path(_PCH_SUM_PATH).read_text() + + +def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None: + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_precompile_headers(${COMPONENT_LIB} PRIVATE\n" + ' "$<$:${CMAKE_CURRENT_SOURCE_DIR}/' + 'esphome/core/pch_prefix.h>"\n)' + ) in content + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert "target_precompile_headers" not in get_component_cmakelists() + + +@pytest.mark.parametrize( + ("file", "content"), + [ + ("src/esphome/core/defines.h", "#define M 2\n"), + ("sdkconfig.test", "CONFIG_X=n\n"), + ("dependencies.lock", "espressif/mdns: 1.13.0\n"), + ], +) +def test_pch_checksum_tracks_its_inputs( + tmp_path: Path, file: str, content: str +) -> None: + """The checksum stands in for the .gch in ccache, so it has to change + with a core header, the sdkconfig and a managed component version.""" + project = _make_pch_project(tmp_path) + first = _pch_checksum() + assert len(first.strip()) == 64 + (project / file).write_text(content) + assert _pch_checksum() != first + + +def test_pch_checksum_is_the_same_for_two_devices(tmp_path: Path) -> None: + sums = [] + for name in ("dev_a", "dev_b"): + _make_pch_project(tmp_path / name) + sums.append(_pch_checksum()) + assert sums[0] == sums[1] + + +def test_pch_checksum_disabled_writes_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + _make_pch_project(tmp_path) + write_pch_checksum() + assert not CORE.relative_build_path(_PCH_SUM_PATH).exists() diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py new file mode 100644 index 00000000000..5a3ba2cdd74 --- /dev/null +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -0,0 +1,111 @@ +"""Tests for esphome.build_helpers.pch.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import pch + + +def _write(src_dir: Path, name: str, content: str) -> None: + path = src_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, True), + ("1", True), + ("0", False), + ("false", False), + ("", False), + ], +) +def test_pch_enabled(value: str | None, expected: bool) -> None: + env = {} if value is None else {"ESPHOME_PCH_ENABLE": value} + with patch.dict(os.environ, env, clear=True): + assert pch.pch_enabled() is expected + + +def test_ccache_pch_env_enabled() -> None: + with patch.dict(os.environ, {}, clear=True): + env = pch.ccache_pch_env() + assert env == { + "CCACHE_SLOPPINESS": "pch_defines,time_macros", + "CCACHE_PCH_EXTSUM": "true", + } + + +def test_ccache_pch_env_keeps_user_values() -> None: + """A user sloppiness list without the pch entries would stop ccache + from caching every compile that loads the .gch.""" + user = {"CCACHE_SLOPPINESS": "locale, time_macros", "CCACHE_PCH_EXTSUM": "false"} + with patch.dict(os.environ, user, clear=True): + assert pch.ccache_pch_env() == { + "CCACHE_SLOPPINESS": "locale,time_macros,pch_defines" + } + + +def test_ccache_pch_env_disabled() -> None: + with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True): + assert pch.ccache_pch_env() == {} + + +def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None: + """Sibling includes resolve against the includer's directory first, + full paths against the src root; unresolvable names end the walk.""" + _write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n') + _write( + tmp_path, + "esphome/components/x/b.h", + '#include "esphome/core/deep.h"\n#include \n#include "missing.h"\n', + ) + _write(tmp_path, "esphome/core/deep.h", "") + closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"]) + assert sorted(closure) == [ + "esphome/components/x/a.h", + "esphome/components/x/b.h", + "esphome/core/deep.h", + ] + + +def test_include_closure_handles_cycles(tmp_path: Path) -> None: + _write(tmp_path, "a.h", '#include "b.h"\n') + _write(tmp_path, "b.h", '#include "a.h"\n') + assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"] + + +def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None: + _write(tmp_path / "src", "a.h", '#include "../outside.h"\n') + (tmp_path / "outside.h").write_text("") + assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"] + + +def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: + """A transitive header edit or an extra-identity change must change the + digest; unrelated files must not.""" + _write(tmp_path, "root.h", '#include "nested.h"\n') + _write(tmp_path, "nested.h", "int a;\n") + _write(tmp_path, "unrelated.h", "int u;\n") + base = pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"]) + _write(tmp_path, "unrelated.h", "int changed;\n") + assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + _write(tmp_path, "nested.h", "int b;\n") + assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) + + +def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None: + """An angle include resolving under src/ must enter the digest; one + that does not simply ends the walk.""" + _write(tmp_path, "a.h", "#include \n#include \n") + (tmp_path / "local.h").write_text("") + closure = pch._include_closure(tmp_path, ["a.h"]) + assert set(closure) == {"a.h", "local.h"} diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index ad9c0bb11fc..0353c8afc83 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -174,3 +174,10 @@ def held_lock() -> Callable[..., Callable[..., None]]: return acquire return make + + +@pytest.fixture(autouse=True) +def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None: + """The precompiled header knobs change what the generators emit; a value + exported in the developer's or CI's shell must not reach the tests.""" + monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False) diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 3eeace99147..9654c7cfc57 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1609,6 +1609,9 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: assert env["CCACHE_NOHASHDIR"] == "true" assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + # The pch cannot cache under ccache without these + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index bb2aab17a24..2d655636632 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -93,6 +93,13 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch) assert toolchain._get_configured_targets() is None +@pytest.fixture(autouse=True) +def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None: + """Deterministic run_compile: no host ccache probe, no pch work.""" + monkeypatch.setenv("IDF_CCACHE_ENABLE", "0") + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + + def _setup_build(setup_core: Path) -> tuple[Path, Path]: """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" CORE.name = "test" @@ -690,6 +697,27 @@ def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: mock_run.assert_called_once_with("build", "size", jobs=None) +def test_run_compile_writes_the_pch_checksum_before_the_build( + setup_core: Path, +) -> None: + _setup_build(setup_core) + order: list[str] = [] + + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object( + toolchain, "run_idf_py", side_effect=lambda *a, **k: order.append("build") + ), + patch.object(toolchain, "print_summary"), + patch( + "esphome.build_gen.espidf.write_pch_checksum", + side_effect=lambda: order.append("checksum"), + ), + ): + toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) + assert order[:2] == ["checksum", "build"] + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION From 446c47f7f86080e19004487a5f7cb558aad28032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 18:51:44 +0200 Subject: [PATCH 663/759] [esp8266] Precompile the prefix header on the native toolchain (#19821) --- .github/workflows/ci-docker.yml | 1 + .../test_configs/esp8266-arduino-native.yaml | 8 ++ esphome/build_gen/arduino8266.py | 35 +++++- esphome/build_helpers/ccache.py | 19 ++- esphome/build_helpers/idedata.py | 9 ++ esphome/build_helpers/ninja_gen.py | 85 ++++++++++++- esphome/build_helpers/pch.py | 62 +++++++++- .../unit_tests/build_gen/test_arduino8266.py | 116 +++++++++++++++++- tests/unit_tests/build_helpers/test_ccache.py | 30 +++++ tests/unit_tests/build_helpers/test_pch.py | 5 + 10 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 docker/test_configs/esp8266-arduino-native.yaml diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index bf099d4ebf7..2784f135114 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -196,6 +196,7 @@ jobs: # the default. id: - esp8266-arduino + - esp8266-arduino-native - esp32-arduino-platformio - esp32-arduino-esp-idf - esp32-idf-platformio diff --git a/docker/test_configs/esp8266-arduino-native.yaml b/docker/test_configs/esp8266-arduino-native.yaml new file mode 100644 index 00000000000..99eab33ecb0 --- /dev/null +++ b/docker/test_configs/esp8266-arduino-native.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-esp8266-native + +esp8266: + board: d1_mini + toolchain: arduino + +logger: diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 4a0e29ab3c9..c66292a8363 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -25,6 +25,7 @@ import subprocess from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool +from esphome.build_helpers.idedata import is_joined_include from esphome.build_helpers.ninja import ( escape as _e, quote_path as _q, @@ -36,8 +37,11 @@ from esphome.build_helpers.ninja_gen import ( compile_edges, compile_rule_lines, library_edges, + pch_edges, + pch_rule_lines, tool_lines, ) +from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import ( BOARDS, @@ -1057,6 +1061,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: ), *compile_rule_lines(), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), + *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", " rspfile = $out.rsp", @@ -1111,7 +1116,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: # One source of truth with the PlatformIO path: esp8266/__init__ pins # build_src_flags (the throw_stubs force-include); -include paths # resolve against the source root - src_parts: list[str] = [] + src_other: list[str] = [] + src_includes: list[str] = [] src_it = iter( lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") ) @@ -1122,15 +1128,32 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: raise EsphomeError( "build_src_flags has a trailing '-include' with no header" ) - src_parts.append(f"-include {_q(src_dir / header)}") + src_includes.append(header) + elif is_joined_include(tok): + # Left in src_other it would precede the pch include + src_includes.append(tok[len("-include") :]) else: - src_parts.append(_shell_token(tok)) - src_extra = " ".join(src_parts) + src_other.append(_shell_token(tok)) + include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) - lines.append(f"srcflags = {src_extra}") + lines.append(f"srcflags = {' '.join(src_other + include_flags)}") + src_cxx_override = pch_edges( + lines, + build_dir, + src_dir, + (*src_includes, *PCH_DEFAULT_HEADERS), + flag_sets.cxxflags, + src_other, + (str(paths.framework), str(paths.toolchain)), + ) src_objs = compile_edges( - lines, collect_sources(src_dir), src_dir, "src", flags="$srcflags" + lines, + collect_sources(src_dir), + src_dir, + "src", + flags="$srcflags", + cxx_override=src_cxx_override, ) ld_deps = [f"ld/{_COMMON_LD_NAME}"] diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 541dcbe3d33..df9aa8c9a09 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -102,15 +102,30 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: return {k: v for k, v in defaults.items() if k not in os.environ} +def effective_ccache_basedir() -> str: + """The prefix ccache strips from hashed paths: a usable user + CCACHE_BASEDIR, else the resolved build path.""" + from esphome.core import CORE + + raw = os.environ.get("CCACHE_BASEDIR") + if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1: + return raw + return str(Path(CORE.build_path).resolve()) + + def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]: """The ccache settings for a build subprocess (not os.environ). ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when disabled; ``tools_cache`` is the backend's tools cache spec, which holds - its ccache dir. + its ccache dir. The pch settings include ``time_macros``: a cached + object can keep an older ``__DATE__`` or ``__TIME__``. """ if ccache is None: return {} + from esphome.build_helpers.pch import ccache_pch_env from esphome.build_helpers.tools_cache import tools_cache_path - return ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache") + env = ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache") + env.update(ccache_pch_env()) + return env diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index 98ee8044030..62bea830225 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -152,6 +152,15 @@ def _is_launcher(token: str) -> bool: return Path(token).stem.lower() in _LAUNCHER_STEMS +def is_joined_include(tok: str) -> bool: + """The joined ``-includefoo.h`` spelling; excludes clang's -include-pch.""" + return ( + tok.startswith("-include") + and tok != "-include" + and not tok.startswith("-include-") + ) + + def parse_entry( entry: dict, launcher: str | None = None ) -> tuple[str, list[str], list[str], list[str]]: diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index d4cfc301ac5..40cc8e7400a 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -13,12 +13,23 @@ from pathlib import Path import sys from typing import TYPE_CHECKING +from esphome.build_helpers.idedata import is_joined_include from esphome.build_helpers.ninja import ( escape as _e, quote_path as _q, shell_token as _shell_token, ) +from esphome.build_helpers.pch import ( + PCH_GCH_NAME, + PCH_SUM_NAME, + log_pch_in_use, + pch_consumer_flags, + pch_enabled, + pch_identity, + write_pch_headers, +) from esphome.framework_helpers import strip_win_long_path_prefix +from esphome.helpers import write_file_if_changed from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX if TYPE_CHECKING: @@ -94,6 +105,62 @@ def compile_rule_lines() -> list[str]: ] +def pch_rule_lines() -> list[str]: + """The precompiled header rule, for a generator that emits one.""" + return [ + # No $ccache: the .gch embeds build dir paths + "rule pch", + " command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = PCH $out", + ] + + +def pch_edges( + lines: list[str], + build_dir: Path, + src_dir: Path, + headers: Sequence[str], + cxxflags: Sequence[str], + src_flags: Sequence[str], + identity: Sequence[str], +) -> tuple[str, str] | None: + """Emit the precompiled header for the C++ src edges. + + ``headers`` are folded into one prefix header, ``src_flags`` are the + flags every src edge carries and ``identity`` names what else the + compile depends on, the compiler included. Returns the ``cxx_override`` + for ``compile_edges``, or None without a pch. + """ + if not pch_enabled(): + return None + if any( + tok == "-include" or tok.startswith("--include") or is_joined_include(tok) + for tok in cxxflags + ): + # $cxxflags expands first and GCC only loads a .gch for the first + # -include + _LOGGER.warning( + "A -include in the compiler flags prevents the precompiled header from " + "loading; compiling without it" + ) + return None + log_pch_in_use() + source = write_pch_headers(build_dir, headers) + write_file_if_changed( + build_dir / PCH_SUM_NAME, + pch_identity([*cxxflags, *src_flags], src_dir, tuple(headers), identity) + "\n", + ) + gch = _e(PCH_GCH_NAME) + # The checksum file changes with anything the .gch depends on + lines.append(f"build {gch}: pch {_e(source)} | {_e(PCH_SUM_NAME)}") + if src_flags: + lines.append(f" flags = {' '.join(src_flags)}") + lines.append(f"srccxxflags = {' '.join([*src_flags, *pch_consumer_flags()])}") + return ("$srccxxflags", gch) + + def ar_rule_lines(ar: Path | str) -> list[str]: return [ "rule ar", @@ -111,20 +178,26 @@ def compile_edges( group: str, flags: str = "", own_includes: str = "", + cxx_override: tuple[str, str] | None = None, ) -> list[str]: - """Emit compile edges for ``sources``; return the object paths.""" + """Emit compile edges for ``sources``; return the object paths. + + ``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges + only, replacing ``flags`` (used for the precompiled header). + """ objects = [] for src in sources: rel = src.relative_to(root).as_posix() obj = f"obj/{group}/{rel}.o" escaped_obj = _e(obj) - lines.append( - f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" - ) + kind = SOURCE_KIND_FOR_SUFFIX[src.suffix] + override = cxx_override if kind == "cxx" else None + implicit = f" | {override[1]}" if override else "" + lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}") if own_includes: lines.append(f" own_includes = {own_includes}") - if flags: - lines.append(f" flags = {flags}") + if edge_flags := override[0] if override else flags: + lines.append(f" flags = {edge_flags}") # Escaped once here: the returned paths only ever appear in build # statements (archive/link inputs), which use ninja escaping objects.append(escaped_obj) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index d93c186c7c4..bfe81d85041 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -10,10 +10,29 @@ from pathlib import Path import posixpath import re -from esphome.build_helpers.ccache import parse_enable_env +from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env +from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) +# The header and its sidecars live in the build directory +PCH_HEADER_NAME = "esphome_pch.h" +PCH_GCH_NAME = f"{PCH_HEADER_NAME}.gch" +# ccache hashes this instead of the .gch; also the freshness stamp +PCH_SUM_NAME = f"{PCH_GCH_NAME}.sum" +# The include list the .gch is compiled from +PCH_SOURCE_NAME = "esphome_pch_src.h" + +# GCC can skip a .gch without a diagnostic and read the header of the same +# name, so that header is an error. Other tools get the include list. +PCH_GUARD_TEXT = f"""\ +#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__) +#error "The precompiled header was not loaded" +#else +#include "{PCH_SOURCE_NAME}" +#endif +""" + # The core headers every backend precompiles PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) @@ -29,6 +48,12 @@ def pch_enabled() -> bool: return parse_enable_env("ESPHOME_PCH_ENABLE") is not False +def pch_consumer_flags() -> list[str]: + """Flags a C++ src compile loads the pch with. The -include stays + relative: an absolute path would enter the ccache key.""" + return ["-Winvalid-pch", "-Werror=invalid-pch", "-include", PCH_HEADER_NAME] + + def ccache_pch_env() -> dict[str, str]: """What ccache needs to cache compiles that load a .gch, added to what the user already set.""" @@ -46,6 +71,20 @@ def ccache_pch_env() -> dict[str, str]: return env +def pch_header_text(include_headers: Iterable[str]) -> str: + """The prefix-header source: exactly these includes, in order.""" + return "".join(f'#include "{name}"\n' for name in include_headers) + + +def write_pch_headers(build_dir: Path, include_headers: Iterable[str]) -> Path: + """Write the guard header and the include list; return the latter, + which is what the .gch compiles from.""" + write_file_if_changed(build_dir / PCH_HEADER_NAME, PCH_GUARD_TEXT) + source = build_dir / PCH_SOURCE_NAME + write_file_if_changed(source, pch_header_text(include_headers)) + return source + + def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]: """Include closure of ``roots``: src-relative name -> contents. @@ -87,6 +126,27 @@ def pch_checksum( return digest.hexdigest() +def pch_identity( + tokens: Iterable[str], + src_dir: Path, + include_headers: tuple[str, ...], + extra: Iterable[str], +) -> str: + """The .sum digest: include closure, header text, ``extra`` and the + compile flags with the build path stripped, as ccache does.""" + from esphome.core import CORE + + flags = ( + " ".join(tokens) + .replace(str(CORE.build_path), "") + .replace(effective_ccache_basedir(), "") + ) + # The closure is sorted, so header order only enters via the text + return pch_checksum( + src_dir, include_headers, (pch_header_text(include_headers), *extra, flags) + ) + + _DISABLE_HINT = " (set ESPHOME_PCH_ENABLE=0 to disable)" diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 65a78c0253e..8bd304b42be 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -364,7 +364,87 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: line for line in content.splitlines() if line.startswith(" flags = ") ] assert flags_lines - assert all(line == " flags = $srcflags" for line in flags_lines) + # C++ src edges consume the precompiled header; C/assembly keep srcflags + assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + + +def test_write_project_pch(tmp_path: Path) -> None: + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + build_dir = CORE.relative_pioenvs_path(CORE.name) + assert "rule pch" in content + # Compiled from the include list, and again when the checksum changes + gch_edge = next( + line + for line in content.splitlines() + if line.startswith("build esphome_pch.h.gch: pch ") + ) + assert gch_edge.endswith("esphome_pch_src.h | esphome_pch.h.gch.sum") + for line in content.splitlines(): + # C++ edges wait on the .gch; the C edge must not reference it + if line.startswith("build obj/src/main.cpp.o:"): + assert line.endswith("| esphome_pch.h.gch") + if line.startswith("build obj/src/esphome/vendor.c.o:"): + assert "esphome_pch" not in line + assert (build_dir / "esphome_pch_src.h").read_text().splitlines() == [ + '#include "esphome/components/esp8266/throw_stubs.h"', + '#include "esphome/core/pch_prefix.h"', + ] + assert "#error" in (build_dir / "esphome_pch.h").read_text() + assert (build_dir / "esphome_pch.h.gch.sum").read_text().strip() + + +def test_write_project_pch_folds_joined_src_force_include( + tmp_path: Path, +) -> None: + """-includefoo.h in build_src_flags must fold into the pch like the + separated spelling, not precede and defeat it.""" + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + CORE.platformio_options["build_src_flags"] = "-includeesphome/core/defines.h" + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "build esphome_pch.h.gch: pch" in content + assert "srccxxflags" in content + assert "-includeesphome" not in content + + +@pytest.mark.parametrize("flag", ["-includefoo.h", "--include=foo.h"]) +def test_write_project_pch_skipped_for_other_force_include_spellings( + tmp_path: Path, caplog: pytest.LogCaptureFixture, flag: str +) -> None: + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", flag) + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "esphome_pch" not in content + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_skipped_when_user_force_include_precedes( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A -include in build_flags lands ahead of the pch include, so GCC + would never load the .gch; skip it and say so.""" + paths = _make_framework(tmp_path) + _set_flags( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", "-include foo.h" + ) + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert "esphome_pch" not in content + assert "srccxxflags" not in content + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths) + assert "esphome_pch" not in content + assert "srccxxflags" not in content + assert " flags = $srcflags" in content def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: @@ -1716,3 +1796,37 @@ def test_write_project_rejects_spaced_ldscript_override(tmp_path: Path) -> None: CORE.relative_src_path().mkdir(parents=True, exist_ok=True) with pytest.raises(EsphomeError, match="Invalid flash linker script name"): arduino8266.write_project(paths, None) + + +def test_write_project_pch_no_device_path_poison(tmp_path: Path) -> None: + """Regression: the -include stays relative and the .sum carries no + per-device path, or cross-device ccache sharing breaks.""" + paths = _make_framework(tmp_path / "shared") + sums = [] + for name in ("dev_a", "dev_b"): + CORE.name = name + CORE.build_path = tmp_path / name + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + content = _write_ninja(paths, ccache="/usr/bin/ccache") + assert ( + "srccxxflags = -Winvalid-pch -Werror=invalid-pch " + "-include esphome_pch.h" in content + ) + sums.append( + (CORE.relative_pioenvs_path(name) / "esphome_pch.h.gch.sum").read_text() + ) + assert sums[0] == sums[1] + + +def test_write_project_pch_sum_tracks_src_flags(tmp_path: Path) -> None: + """The header compiles with the src flags too, so they are part of what + ccache is told about it.""" + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + sum_path = CORE.relative_pioenvs_path(CORE.name) / "esphome_pch.h.gch.sum" + sums = [] + for value in ("1", "2"): + CORE.platformio_options["build_src_flags"] = f"-DSRC_ONLY={value}" + _write_ninja(paths) + sums.append(sum_path.read_text()) + assert sums[0] != sums[1] diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index 468a97daab3..17d79b1ddb6 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from esphome.build_helpers import ccache +from esphome.core import CORE def test_resolve_opt_out() -> None: @@ -122,6 +123,20 @@ def test_parse_enable_env_spelling_tables( assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected +def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None: + CORE.build_path = tmp_path + # Drive-qualified on Windows: "/custom/base" is not absolute there + base = "C:\\custom\\base" if os.name == "nt" else "/custom/base" + with patch.dict(os.environ, {"CCACHE_BASEDIR": base}, clear=True): + assert ccache.effective_ccache_basedir() == base + with patch.dict(os.environ, {}, clear=True): + assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) + # Degenerate values would strip substrings ccache never rewrites + for bad in ("", "/", "a/b"): + with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True): + assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) + + def test_resolve_absolute_ccache_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -165,3 +180,18 @@ def test_ccache_defaults_env_escapes_a_dollar( env = ccache.ccache_defaults_env(tmp_path / "c$d") assert env["CCACHE_DIR"].endswith("c$$d") assert env["CCACHE_BASEDIR"].endswith("a$$b") + + +def _pch_ccache_env(tmp_path: Path, environ: dict[str, str]) -> dict[str, str]: + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + environ = {"ESPHOME_TEST_PREFIX": str(tmp_path / "cache"), **environ} + with patch.dict(os.environ, environ, clear=True): + return ccache.ccache_env("/usr/bin/ccache", spec) + + +def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None: + """A native build exports the ccache settings the pch needs.""" + env = _pch_ccache_env(tmp_path, {}) + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 5a3ba2cdd74..99109bec5e3 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -57,6 +57,11 @@ def test_ccache_pch_env_disabled() -> None: assert pch.ccache_pch_env() == {} +def test_pch_header_text_preserves_order() -> None: + text = pch.pch_header_text(["b.h", "a.h"]) + assert text == '#include "b.h"\n#include "a.h"\n' + + def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None: """Sibling includes resolve against the includer's directory first, full paths against the src root; unresolvable names end the walk.""" From 6c2a4d39996b04f7e0d38bfab13b7fd1279dbd96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 18:58:27 +0200 Subject: [PATCH 664/759] [host] Precompile the core headers on the native build (#19822) --- esphome/build_gen/host.py | 32 +++++++++- tests/unit_tests/build_gen/test_host.py | 81 +++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index c883d912eb9..4f7c556a488 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -15,6 +15,7 @@ from collections.abc import Iterable import logging import os from pathlib import Path +import subprocess import sys from typing import TYPE_CHECKING @@ -28,8 +29,11 @@ from esphome.build_helpers.ninja_gen import ( compile_edges, compile_rule_lines, library_edges, + pch_edges, + pch_rule_lines, tool_lines, ) +from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_enabled from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags from esphome.helpers import mkdir_p, write_file_if_changed @@ -189,6 +193,15 @@ def _file_macro_maps(build_dir: Path) -> list[str]: ] +def _compiler_version(cxx: tuple[str, ...]) -> str: + """What the compiler says it is: its path can stay the same across an + update (the macOS shims in /usr/bin).""" + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + return result.stdout + + def write_project(compilers: HostCompilers, ccache: str | None) -> bool: """Write the ninja build for the current configuration. @@ -228,6 +241,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), *compile_rule_lines(), + *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", " rspfile = $out.rsp", @@ -251,7 +265,23 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: archives, direct_objs = library_edges(lines, libraries) - src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src") + src_cxx_override = pch_edges( + lines, + build_dir, + src_dir, + PCH_DEFAULT_HEADERS, + # The arguments of a CXX override come before the flags + [*compilers.cxx[1:], *cxxflags], + (), + (compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (), + ) + src_objs = compile_edges( + lines, + collect_sources(src_dir), + src_dir, + "src", + cxx_override=src_cxx_override, + ) if not src_objs: raise EsphomeError(f"No source files found under {src_dir}") diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index d7fa73e897d..9e40698c91c 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os from pathlib import Path +import sys from unittest.mock import patch import pytest @@ -37,8 +38,13 @@ def _make_src(tmp_path: Path, *names: str) -> Path: return src -def _render(ccache: str | None = None) -> tuple[bool, str]: - changed = build_gen.write_project(COMPILERS, ccache) +def _render( + ccache: str | None = None, + version: str = "g++ 1.0", + compilers: HostCompilers = COMPILERS, +) -> tuple[bool, str]: + with patch.object(build_gen, "_compiler_version", return_value=version): + changed = build_gen.write_project(compilers, ccache) ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" return changed, ninja.read_text() @@ -189,7 +195,7 @@ def test_write_project_requires_generated_sources(tmp_path: Path) -> None: build_gen.write_project(COMPILERS, None) _make_src(tmp_path, "esphome.h") with pytest.raises(EsphomeError, match="No source files found"): - build_gen.write_project(COMPILERS, None) + _render() def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: @@ -207,7 +213,7 @@ def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: ) assert "build obj/src/x.S.o: aspp " in ninja assert "build obj/src/y.s.o: asm " in ninja - assert "h.h" not in ninja + assert _e(src / "h.h") not in ninja # __FILE__ stays relative to the build path, as it was under PlatformIO, # whether the source arrives absolute or rewritten by ccache sep = os.sep @@ -232,6 +238,73 @@ def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: assert changed is False +def test_write_project_precompiles_the_core_headers(tmp_path: Path) -> None: + """C++ src edges load one precompiled prefix; C and assembly do not.""" + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S") + _, ninja = _render() + build_dir = tmp_path / ".pioenvs" / "dev" + source = build_dir / "esphome_pch_src.h" + assert source.read_text() == '#include "esphome/core/pch_prefix.h"\n' + assert "rule pch\n" in ninja + sum_path = build_dir / "esphome_pch.h.gch.sum" + assert ( + f"build esphome_pch.h.gch: pch {_e(source)} | esphome_pch.h.gch.sum\n" in ninja + ) + assert "srccxxflags = -Winvalid-pch " in ninja + assert ( + f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')} | esphome_pch.h.gch\n" + " flags = $srccxxflags\n" + ) in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}\n" + ) in ninja + assert f"build obj/src/x.S.o: aspp {_e(src / 'x.S')}\n" in ninja + # An updated compiler behind the same path rebuilds the header + first = sum_path.read_text() + _render(version="g++ 2.0") + assert sum_path.read_text() != first + + +def test_compiler_version_asks_the_compiler() -> None: + assert build_gen._compiler_version((sys.executable,)).startswith("Python ") + + +def test_write_project_pch_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src = _make_src(tmp_path, "main.cpp") + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + # The compiler is not asked for its version either + with patch.object(build_gen, "_compiler_version", side_effect=AssertionError): + build_gen.write_project(COMPILERS, None) + ninja = (tmp_path / ".pioenvs" / "dev" / "build.ninja").read_text() + assert "esphome_pch" not in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}\n" in ninja + + +def test_write_project_pch_skipped_for_a_user_force_include( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A -include in the build flags precedes the prefix, so the compiler + would never load the precompiled header.""" + _make_src(tmp_path, "main.cpp") + CORE.build_flags = {"-include pre.h"} + _, ninja = _render() + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + +def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """CXX="g++ -include pre.h" puts it ahead of every flag.""" + _make_src(tmp_path, "main.cpp") + override = HostCompilers(cc=COMPILERS.cc, cxx=("/usr/bin/g++", "-include", "pre.h")) + _, ninja = _render(compilers=override) + assert "esphome_pch" not in ninja + assert "prevents the precompiled header" in caplog.text + + def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() From 1f08ea68e6a1886f00e55e2adddb650657ae37b4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:12:41 +0000 Subject: [PATCH 665/759] [ci] Refresh integration test durations (#19847) --- .../integration_test_durations.json | 324 +++++++++--------- 1 file changed, 162 insertions(+), 162 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index f9f1a43373c..4f2e245e129 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,164 +1,164 @@ { - "tests/integration/test_action_concurrent_reentry.py": 31.66, - "tests/integration/test_addressable_light_transition.py": 40.49, - "tests/integration/test_alarm_control_panel_state_transitions.py": 28.84, - "tests/integration/test_api_action_metadata.py": 20.54, - "tests/integration/test_api_action_responses.py": 35.05, - "tests/integration/test_api_action_timeout.py": 25.85, - "tests/integration/test_api_conditional_memory.py": 33.44, - "tests/integration/test_api_custom_services.py": 21.85, - "tests/integration/test_api_decode_wire_types.py": 38.03, - "tests/integration/test_api_empty_message_roundtrip.py": 20.53, - "tests/integration/test_api_encode_boundaries.py": 28.74, - "tests/integration/test_api_get_time_response_timezone.py": 26.53, - "tests/integration/test_api_homeassistant.py": 38.17, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 21.45, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 20.86, - "tests/integration/test_api_list_entities_backpressure.py": 24.14, - "tests/integration/test_api_message_size_batching.py": 32.91, - "tests/integration/test_api_reboot_timeout.py": 21.49, - "tests/integration/test_api_string_lambda.py": 22.48, - "tests/integration/test_api_vv_logging.py": 25.08, - "tests/integration/test_api_zero_psk_provisioning.py": 40.67, - "tests/integration/test_areas_and_devices.py": 26.02, - "tests/integration/test_automation_wait_actions.py": 23.49, - "tests/integration/test_automations.py": 49.82, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 27.22, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 23.85, - "tests/integration/test_binary_sensor_invalidate_state.py": 23.49, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.73, - "tests/integration/test_build_info.py": 23.7, - "tests/integration/test_camera_mock.py": 16.57, - "tests/integration/test_climate_control_action.py": 25.09, - "tests/integration/test_climate_custom_modes.py": 26.78, - "tests/integration/test_continuation_actions.py": 20.51, - "tests/integration/test_cover_control_action.py": 24.95, - "tests/integration/test_crc8_helper.py": 17.19, - "tests/integration/test_device_id_in_state.py": 58.05, - "tests/integration/test_duplicate_entities.py": 30.23, - "tests/integration/test_entity_icon.py": 30.68, - "tests/integration/test_entity_missing_state.py": 27.81, - "tests/integration/test_fan_turn_on_action.py": 23.22, - "tests/integration/test_fnv1_hash_object_id.py": 25.75, - "tests/integration/test_fnv1a_hash.py": 3.64, - "tests/integration/test_gpio_expander_cache.py": 22.24, - "tests/integration/test_host_logger_thread_safety.py": 20.82, - "tests/integration/test_host_mode_basic.py": 4.96, - "tests/integration/test_host_mode_batch_delay.py": 24.14, - "tests/integration/test_host_mode_climate_basic_state.py": 27.28, - "tests/integration/test_host_mode_climate_control.py": 28.31, - "tests/integration/test_host_mode_empty_string_options.py": 27.5, - "tests/integration/test_host_mode_entity_fields.py": 30.67, - "tests/integration/test_host_mode_fan_preset.py": 24.14, - "tests/integration/test_host_mode_many_entities.py": 51.6, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 31.89, - "tests/integration/test_host_mode_noise_encryption.py": 40.77, - "tests/integration/test_host_mode_reconnect.py": 12.62, - "tests/integration/test_host_mode_sensor.py": 22.41, - "tests/integration/test_host_ota.py": 110.14, - "tests/integration/test_host_preferences.py": 24.78, - "tests/integration/test_host_preferences_suspend_resume.py": 22.95, - "tests/integration/test_improv_serial_uart.py": 18.2, - "tests/integration/test_large_message_batching.py": 39.36, - "tests/integration/test_legacy_area.py": 21.25, - "tests/integration/test_legacy_climate_compat.py": 25.65, - "tests/integration/test_legacy_fan_compat.py": 23.25, - "tests/integration/test_light_automations.py": 27.09, - "tests/integration/test_light_binary_effect_off_phase.py": 26.9, - "tests/integration/test_light_calls.py": 31.49, - "tests/integration/test_light_constant_brightness.py": 31.64, - "tests/integration/test_light_control_action.py": 24.78, - "tests/integration/test_light_dim_relative_action.py": 23.69, - "tests/integration/test_light_effect_zero_brightness.py": 25.32, - "tests/integration/test_light_initial_state.py": 24.22, - "tests/integration/test_light_restore_state.py": 23.08, - "tests/integration/test_light_resume_effect.py": 30.38, - "tests/integration/test_light_toggle_action.py": 24.42, - "tests/integration/test_light_transition_state_publish_interval.py": 71.09, - "tests/integration/test_lock_automations.py": 21.54, - "tests/integration/test_logger_buffered_recursion_guard.py": 12.66, - "tests/integration/test_loop_disable_enable.py": 22.87, - "tests/integration/test_loop_interval_decoupling.py": 36.3, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 25.88, - "tests/integration/test_lvgl_headless_render.py": 95.64, - "tests/integration/test_micros_to_millis.py": 27.1, - "tests/integration/test_multi_click_trigger.py": 23.09, - "tests/integration/test_multi_device_preferences.py": 28.62, - "tests/integration/test_noise_encryption_key_protection.py": 32.04, - "tests/integration/test_object_id_api_verification.py": 36.37, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 22.4, - "tests/integration/test_object_id_no_friendly_name.py": 42.47, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 50.34, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 5.45, - "tests/integration/test_online_image_bmp.py": 43.63, - "tests/integration/test_oversized_payloads.py": 52.45, - "tests/integration/test_preference_key_stability.py": 26.26, - "tests/integration/test_runtime_stats.py": 28.11, - "tests/integration/test_safe_mode_loop_runs.py": 10.73, - "tests/integration/test_scheduler_blocking_warning.py": 53.72, - "tests/integration/test_scheduler_bulk_cleanup.py": 21.74, - "tests/integration/test_scheduler_defer_cancel.py": 24.06, - "tests/integration/test_scheduler_defer_cancel_regular.py": 21.48, - "tests/integration/test_scheduler_defer_fifo_simple.py": 23.68, - "tests/integration/test_scheduler_defer_stress.py": 23.57, - "tests/integration/test_scheduler_heap_stress.py": 23.12, - "tests/integration/test_scheduler_internal_id_no_collision.py": 22.64, - "tests/integration/test_scheduler_interval_reschedule.py": 20.29, - "tests/integration/test_scheduler_interval_zero_coerced.py": 20.9, - "tests/integration/test_scheduler_null_name.py": 20.45, - "tests/integration/test_scheduler_numeric_id_test.py": 23.77, - "tests/integration/test_scheduler_pool.py": 24.07, - "tests/integration/test_scheduler_rapid_cancellation.py": 25.39, - "tests/integration/test_scheduler_recursive_timeout.py": 5.31, - "tests/integration/test_scheduler_removed_item_race.py": 21.95, - "tests/integration/test_scheduler_self_keyed.py": 5.81, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.44, - "tests/integration/test_scheduler_string_test.py": 24.38, - "tests/integration/test_script_array_params.py": 16.92, - "tests/integration/test_script_delay_params.py": 14.32, - "tests/integration/test_script_queued.py": 25.32, - "tests/integration/test_script_queued_idle_loop.py": 19.93, - "tests/integration/test_script_wait_on_boot.py": 23.34, - "tests/integration/test_sdl_headless_screenshot.py": 26.0, - "tests/integration/test_select_stringref_trigger.py": 22.38, - "tests/integration/test_sensor_filters_delta.py": 25.72, - "tests/integration/test_sensor_filters_ring_buffer.py": 24.23, - "tests/integration/test_sensor_filters_sliding_window.py": 70.81, - "tests/integration/test_sensor_filters_value_list.py": 22.96, - "tests/integration/test_sensor_raw_state.py": 43.36, - "tests/integration/test_sensor_timeout_filter.py": 22.93, - "tests/integration/test_set_internal_at_boot.py": 23.86, - "tests/integration/test_snapshot_display.py": 26.76, - "tests/integration/test_socket_wake_gate_tcp.py": 21.23, - "tests/integration/test_status_flags.py": 35.38, - "tests/integration/test_strftime_to.py": 22.15, - "tests/integration/test_syslog.py": 28.45, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.28, - "tests/integration/test_template_climate_basic.py": 20.56, - "tests/integration/test_template_climate_custom_modes.py": 25.65, - "tests/integration/test_template_climate_nonoptimistic.py": 19.89, - "tests/integration/test_template_climate_on_control_ordering.py": 21.98, - "tests/integration/test_template_climate_publish_all_fields.py": 25.12, - "tests/integration/test_template_climate_sensor_push.py": 25.02, - "tests/integration/test_template_climate_set_actions.py": 21.45, - "tests/integration/test_template_climate_two_point_temperature.py": 24.62, - "tests/integration/test_template_text_save.py": 24.66, - "tests/integration/test_template_time.py": 18.48, - "tests/integration/test_text_command.py": 23.93, - "tests/integration/test_text_sensor_raw_state.py": 22.68, - "tests/integration/test_uart_mock_ld2410.py": 60.58, - "tests/integration/test_uart_mock_ld2412.py": 79.12, - "tests/integration/test_uart_mock_ld2420.py": 40.95, - "tests/integration/test_uart_mock_ld2450.py": 30.8, - "tests/integration/test_uart_mock_modbus.py": 554.44, - "tests/integration/test_udp.py": 9.61, - "tests/integration/test_use_address_runtime.py": 29.41, - "tests/integration/test_valve_control_action.py": 21.9, - "tests/integration/test_varint_five_byte_device_id.py": 23.6, - "tests/integration/test_wait_until_mid_loop_timing.py": 21.89, - "tests/integration/test_wait_until_on_boot.py": 22.57, - "tests/integration/test_wait_until_ordering.py": 21.27, - "tests/integration/test_wait_until_reentrant_restart.py": 18.84, - "tests/integration/test_wake_loop_forces_phase_b.py": 22.68, - "tests/integration/test_water_heater_template.py": 41.77 + "tests/integration/test_action_concurrent_reentry.py": 14.22, + "tests/integration/test_addressable_light_transition.py": 12.87, + "tests/integration/test_alarm_control_panel_state_transitions.py": 10.89, + "tests/integration/test_api_action_metadata.py": 8.48, + "tests/integration/test_api_action_responses.py": 14.99, + "tests/integration/test_api_action_timeout.py": 17.11, + "tests/integration/test_api_conditional_memory.py": 12.13, + "tests/integration/test_api_custom_services.py": 13.35, + "tests/integration/test_api_decode_wire_types.py": 11.09, + "tests/integration/test_api_empty_message_roundtrip.py": 11.44, + "tests/integration/test_api_encode_boundaries.py": 14.78, + "tests/integration/test_api_get_time_response_timezone.py": 13.95, + "tests/integration/test_api_homeassistant.py": 16.39, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 10.59, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.64, + "tests/integration/test_api_list_entities_backpressure.py": 13.25, + "tests/integration/test_api_message_size_batching.py": 9.9, + "tests/integration/test_api_reboot_timeout.py": 12.0, + "tests/integration/test_api_string_lambda.py": 9.04, + "tests/integration/test_api_vv_logging.py": 12.89, + "tests/integration/test_api_zero_psk_provisioning.py": 17.56, + "tests/integration/test_areas_and_devices.py": 9.95, + "tests/integration/test_automation_wait_actions.py": 11.31, + "tests/integration/test_automations.py": 26.82, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 10.76, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 11.71, + "tests/integration/test_binary_sensor_invalidate_state.py": 9.11, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.55, + "tests/integration/test_build_info.py": 8.14, + "tests/integration/test_camera_mock.py": 9.88, + "tests/integration/test_climate_control_action.py": 12.5, + "tests/integration/test_climate_custom_modes.py": 10.59, + "tests/integration/test_continuation_actions.py": 10.74, + "tests/integration/test_cover_control_action.py": 11.06, + "tests/integration/test_crc8_helper.py": 9.21, + "tests/integration/test_device_id_in_state.py": 20.43, + "tests/integration/test_duplicate_entities.py": 12.01, + "tests/integration/test_entity_icon.py": 14.29, + "tests/integration/test_entity_missing_state.py": 11.84, + "tests/integration/test_fan_turn_on_action.py": 13.41, + "tests/integration/test_fnv1_hash_object_id.py": 6.02, + "tests/integration/test_fnv1a_hash.py": 9.59, + "tests/integration/test_gpio_expander_cache.py": 7.68, + "tests/integration/test_host_logger_thread_safety.py": 12.31, + "tests/integration/test_host_mode_basic.py": 9.92, + "tests/integration/test_host_mode_batch_delay.py": 8.99, + "tests/integration/test_host_mode_climate_basic_state.py": 13.73, + "tests/integration/test_host_mode_climate_control.py": 10.2, + "tests/integration/test_host_mode_empty_string_options.py": 11.81, + "tests/integration/test_host_mode_entity_fields.py": 14.54, + "tests/integration/test_host_mode_fan_preset.py": 12.02, + "tests/integration/test_host_mode_many_entities.py": 22.44, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 12.55, + "tests/integration/test_host_mode_noise_encryption.py": 25.74, + "tests/integration/test_host_mode_reconnect.py": 6.67, + "tests/integration/test_host_mode_sensor.py": 11.49, + "tests/integration/test_host_ota.py": 112.15, + "tests/integration/test_host_preferences.py": 12.61, + "tests/integration/test_host_preferences_suspend_resume.py": 11.62, + "tests/integration/test_improv_serial_uart.py": 8.08, + "tests/integration/test_large_message_batching.py": 10.44, + "tests/integration/test_legacy_area.py": 7.85, + "tests/integration/test_legacy_climate_compat.py": 13.36, + "tests/integration/test_legacy_fan_compat.py": 10.6, + "tests/integration/test_light_automations.py": 10.63, + "tests/integration/test_light_binary_effect_off_phase.py": 20.09, + "tests/integration/test_light_calls.py": 15.97, + "tests/integration/test_light_constant_brightness.py": 15.27, + "tests/integration/test_light_control_action.py": 13.14, + "tests/integration/test_light_dim_relative_action.py": 10.89, + "tests/integration/test_light_effect_zero_brightness.py": 11.44, + "tests/integration/test_light_initial_state.py": 8.19, + "tests/integration/test_light_restore_state.py": 10.78, + "tests/integration/test_light_resume_effect.py": 12.26, + "tests/integration/test_light_toggle_action.py": 10.53, + "tests/integration/test_light_transition_state_publish_interval.py": 43.37, + "tests/integration/test_lock_automations.py": 10.88, + "tests/integration/test_logger_buffered_recursion_guard.py": 8.93, + "tests/integration/test_loop_disable_enable.py": 11.81, + "tests/integration/test_loop_interval_decoupling.py": 14.96, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 15.07, + "tests/integration/test_lvgl_headless_render.py": 55.09, + "tests/integration/test_micros_to_millis.py": 7.96, + "tests/integration/test_multi_click_trigger.py": 14.25, + "tests/integration/test_multi_device_preferences.py": 11.2, + "tests/integration/test_noise_encryption_key_protection.py": 20.23, + "tests/integration/test_object_id_api_verification.py": 15.03, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 9.16, + "tests/integration/test_object_id_no_friendly_name.py": 13.66, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 22.28, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 28.84, + "tests/integration/test_online_image_bmp.py": 14.85, + "tests/integration/test_oversized_payloads.py": 31.19, + "tests/integration/test_preference_key_stability.py": 8.3, + "tests/integration/test_runtime_stats.py": 14.29, + "tests/integration/test_safe_mode_loop_runs.py": 7.53, + "tests/integration/test_scheduler_blocking_warning.py": 26.59, + "tests/integration/test_scheduler_bulk_cleanup.py": 9.11, + "tests/integration/test_scheduler_defer_cancel.py": 7.84, + "tests/integration/test_scheduler_defer_cancel_regular.py": 8.05, + "tests/integration/test_scheduler_defer_fifo_simple.py": 12.32, + "tests/integration/test_scheduler_defer_stress.py": 12.6, + "tests/integration/test_scheduler_heap_stress.py": 7.94, + "tests/integration/test_scheduler_internal_id_no_collision.py": 13.8, + "tests/integration/test_scheduler_interval_reschedule.py": 8.2, + "tests/integration/test_scheduler_interval_zero_coerced.py": 8.34, + "tests/integration/test_scheduler_null_name.py": 11.88, + "tests/integration/test_scheduler_numeric_id_test.py": 13.83, + "tests/integration/test_scheduler_pool.py": 13.71, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.5, + "tests/integration/test_scheduler_recursive_timeout.py": 2.26, + "tests/integration/test_scheduler_removed_item_race.py": 10.73, + "tests/integration/test_scheduler_self_keyed.py": 9.9, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.04, + "tests/integration/test_scheduler_string_test.py": 13.88, + "tests/integration/test_script_array_params.py": 12.34, + "tests/integration/test_script_delay_params.py": 13.26, + "tests/integration/test_script_queued.py": 15.09, + "tests/integration/test_script_queued_idle_loop.py": 9.25, + "tests/integration/test_script_wait_on_boot.py": 12.55, + "tests/integration/test_sdl_headless_screenshot.py": 15.02, + "tests/integration/test_select_stringref_trigger.py": 13.35, + "tests/integration/test_sensor_filters_delta.py": 10.95, + "tests/integration/test_sensor_filters_ring_buffer.py": 12.24, + "tests/integration/test_sensor_filters_sliding_window.py": 36.89, + "tests/integration/test_sensor_filters_value_list.py": 13.19, + "tests/integration/test_sensor_raw_state.py": 22.36, + "tests/integration/test_sensor_timeout_filter.py": 7.08, + "tests/integration/test_set_internal_at_boot.py": 10.04, + "tests/integration/test_snapshot_display.py": 14.16, + "tests/integration/test_socket_wake_gate_tcp.py": 11.87, + "tests/integration/test_status_flags.py": 16.85, + "tests/integration/test_strftime_to.py": 9.78, + "tests/integration/test_syslog.py": 19.32, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 10.29, + "tests/integration/test_template_climate_basic.py": 8.97, + "tests/integration/test_template_climate_custom_modes.py": 12.47, + "tests/integration/test_template_climate_nonoptimistic.py": 13.76, + "tests/integration/test_template_climate_on_control_ordering.py": 4.05, + "tests/integration/test_template_climate_publish_all_fields.py": 11.22, + "tests/integration/test_template_climate_sensor_push.py": 9.25, + "tests/integration/test_template_climate_set_actions.py": 9.75, + "tests/integration/test_template_climate_two_point_temperature.py": 8.42, + "tests/integration/test_template_text_save.py": 13.08, + "tests/integration/test_template_time.py": 11.6, + "tests/integration/test_text_command.py": 8.74, + "tests/integration/test_text_sensor_raw_state.py": 8.84, + "tests/integration/test_uart_mock_ld2410.py": 24.25, + "tests/integration/test_uart_mock_ld2412.py": 25.28, + "tests/integration/test_uart_mock_ld2420.py": 19.0, + "tests/integration/test_uart_mock_ld2450.py": 13.48, + "tests/integration/test_uart_mock_modbus.py": 207.43, + "tests/integration/test_udp.py": 12.28, + "tests/integration/test_use_address_runtime.py": 15.24, + "tests/integration/test_valve_control_action.py": 13.21, + "tests/integration/test_varint_five_byte_device_id.py": 11.19, + "tests/integration/test_wait_until_mid_loop_timing.py": 6.22, + "tests/integration/test_wait_until_on_boot.py": 8.33, + "tests/integration/test_wait_until_ordering.py": 10.08, + "tests/integration/test_wait_until_reentrant_restart.py": 12.43, + "tests/integration/test_wake_loop_forces_phase_b.py": 10.22, + "tests/integration/test_water_heater_template.py": 24.1 } From c52ce65d1c5b0f7e241831f071291cc59e4979a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 19:19:46 +0200 Subject: [PATCH 666/759] [esp8266] Precompile the prefix header on PlatformIO builds (#19823) --- esphome/build_gen/platformio.py | 6 + esphome/build_helpers/pch.py | 30 ++ esphome/platformio/pch.py.script | 206 ++++++++++ esphome/platformio/toolchain.py | 13 + esphome/writer.py | 5 + tests/unit_tests/build_gen/test_platformio.py | 35 ++ tests/unit_tests/build_helpers/test_pch.py | 16 + .../unit_tests/test_platformio_pch_script.py | 383 ++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 24 ++ tests/unit_tests/test_writer.py | 23 ++ 10 files changed, 741 insertions(+) create mode 100644 esphome/platformio/pch.py.script create mode 100644 tests/unit_tests/test_platformio_pch_script.py diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 0a12d344a02..b68ee90f123 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,6 +1,8 @@ +from esphome.build_helpers.pch import pch_script_enabled from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed +from esphome.platformio.toolchain import copy_pch_script from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" @@ -62,6 +64,8 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + if pch_script_enabled(): + CORE.add_platformio_option("extra_scripts", ["post:pch.py"]) # Add CMake args. A user-supplied value (str or list) is deliberately # replaced; this option was always overwritten at FINAL priority. @@ -106,6 +110,8 @@ def write_project(): # Write extra script for C++ specific flags write_cxx_flags_script() + if pch_script_enabled(): + copy_pch_script() CXX_FLAGS_FILE_NAME = "cxx_flags.py" diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index bfe81d85041..7889978f066 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -11,6 +11,14 @@ import posixpath import re from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env +from esphome.const import ( + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_LN882X, + PLATFORM_NRF52, + PLATFORM_RP2, + PLATFORM_RTL87XX, +) from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -33,9 +41,24 @@ PCH_GUARD_TEXT = f"""\ #endif """ +# What the PlatformIO script leaves in the project root, for cleanup +PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME) + # The core headers every backend precompiles PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) +# PlatformIO platforms that do not take the pch script +PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( + { + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_LN882X, + PLATFORM_NRF52, + PLATFORM_RP2, + PLATFORM_RTL87XX, + } +) + # What ccache needs to cache compiles that load a .gch _CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros") @@ -71,6 +94,13 @@ def ccache_pch_env() -> dict[str, str]: return env +def pch_script_enabled() -> bool: + """Whether this PlatformIO build takes the pch script.""" + from esphome.core import CORE + + return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS + + def pch_header_text(include_headers: Iterable[str]) -> str: """The prefix-header source: exactly these includes, in order.""" return "".join(f'#include "{name}"\n' for name in include_headers) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script new file mode 100644 index 00000000000..962f5c3f2c2 --- /dev/null +++ b/esphome/platformio/pch.py.script @@ -0,0 +1,206 @@ +import hashlib +import os +from pathlib import Path +import posixpath +import re +import shlex +import subprocess + +# pylint: disable=E0602 +Import("env") # noqa: F821 +try: + Import("projenv") # noqa: F821 +except Exception: # noqa: BLE001 -- not exported under -t nobuild + projenv = None + +# Precompile the force-includes plus the core headers and force-include +# the result into C++ src compiles only. Any failure stops the build. This +# script cannot import esphome: a test pins the values below to +# build_helpers/pch.py. +_HEADER_NAME = "esphome_pch.h" +_SOURCE_NAME = "esphome_pch_src.h" +_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) +_CONSUMER_FLAGS = ["-Winvalid-pch", "-Werror=invalid-pch", "-include", _HEADER_NAME] +_GUARD_TEXT = f"""\ +#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__) +#error "The precompiled header was not loaded" +#else +#include "{_SOURCE_NAME}" +#endif +""" +_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) +_CXX_SUFFIXES = (".cpp", ".cc", ".cxx") +_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc") +_INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter") +# Build output and library trees; package versions stand in for libraries +_SKIPPED_DIRS = (".pioenvs", ".piolibdeps", "managed_components") + + +def _include_closure(src_dir: Path, roots: list) -> dict: + """Include closure: src-relative name -> contents.""" + seen = {} + stack = [(name, "") for name in roots] + while stack: + name, from_dir = stack.pop() + for candidate in (f"{from_dir}/{name}" if from_dir else name, name): + rel = posixpath.normpath(candidate) + if not rel.startswith("..") and (src_dir / rel).is_file(): + break + else: + continue + if rel in seen: + continue + data = seen[rel] = (src_dir / rel).read_bytes() + parent = posixpath.dirname(rel) + stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data)) + return seen + + +def _shell_arg(element) -> str: + """One compiler argument from one SCons element, as the real spawn + passes it (shlex would eat path backslashes on Windows).""" + arg = str(element) + if " " in arg or os.name == "nt": + return arg.replace('\\"', '"') + return (shlex.split(arg) or [""])[0] + + +def _src_envs(src_dir: Path) -> list: + """The environments the C++ src objects compile from: projenv, or the + ESP-IDF builder's own.""" + found = {} + src_dir = src_dir.resolve() + nodes = env.Flatten(env.get("PIOBUILDFILES", [])) # noqa: F821 + for node in nodes: + source = Path(str(node.sources[0])).resolve() + if source.suffix in _CXX_SUFFIXES and source.is_relative_to(src_dir): + found[id(node.env)] = node.env + if nodes and not found: + # Flags on projenv would reach no compile + raise RuntimeError("ESPHome: no C++ source takes the precompiled header") + return list(found.values()) or [projenv] # noqa: F821 + + +def _setup_pch() -> None: + if projenv is None: + if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821 + return + raise RuntimeError("ESPHome: no project environment for the pch") + # SCons compiles run in the project root, so the relative -include + # resolves; an absolute path would enter the ccache key + proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821 + src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821 + header = proj_dir / _HEADER_NAME + source = proj_dir / _SOURCE_NAME + gch = Path(f"{header}.gch") + sum_path = Path(f"{gch}.sum") + src_envs = _src_envs(src_dir) + + cxx = src_envs[0].subst("$CXX") + # The header holds the -include entries itself, so the .gch compile must + # not see them; consumers keep theirs, which the .gch then satisfies + flags = [] + include_headers = [] + flag_it = iter( + _shell_arg(element) + for element in src_envs[0].subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] + ) + for tok in flag_it: + if tok == "-include": + include_headers.append(next(flag_it, "")) + elif tok.startswith("-include") and not tok.startswith("-include-"): + include_headers.append(tok[len("-include") :]) + else: + flags.append(tok) + # Folded first and in order, as the compiler would have read them. An + # absolute path would enter the checksum, so it cannot be folded + if absolute := [name for name in include_headers if Path(name).is_absolute()]: + print( + f"ESPHome: the force-include of {absolute} prevents the precompiled " + "header; compiling without it" + ) + return + headers = [*include_headers, *_DEFAULT_HEADERS] + content = "".join(f'#include "{name}"\n' for name in headers) + + digest = hashlib.sha256() + digest.update(content.encode()) + digest.update(cxx.encode()) + # As ccache does: strip the build path so devices share cache entries + flags_id = " ".join(flags) + if basedir := os.environ.get("CCACHE_BASEDIR"): + flags_id = flags_id.replace(basedir, "") + digest.update(flags_id.encode()) + # GCC does not check a .gch against its headers and PlatformIO package + # paths carry no version + platform = env.PioPlatform() # noqa: F821 + for package in sorted(platform.packages): + digest.update(f"{package}={platform.get_package_version(package)}\0".encode()) + # Libraries carry their version in lib_deps + digest.update(repr(env.GetProjectOption("lib_deps", [])).encode()) # noqa: F821 + closure = _include_closure(src_dir, headers) + for rel in sorted(closure): + digest.update(rel.encode()) + digest.update(closure[rel]) + digest.update(b"\0") + # The ESP-IDF configuration: sdkconfig.h sits in the build output + for config in sorted(proj_dir.glob("sdkconfig.*")): + digest.update(config.read_bytes()) + # Project-local include dirs (rp2's lwip_override) hold generated + # headers outside src/ + prev = "" + for tok in flags: + inc = "" + for opt in _INCLUDE_DIR_OPTIONS: + if prev == opt: + inc = tok + elif tok.startswith(opt) and len(tok) > len(opt): + inc = tok[len(opt) :] + prev = tok + # Relative dirs are relative to the project, where compiles run + inc_dir = Path(os.path.normpath(proj_dir / inc)) + if ( + not inc + or inc_dir == proj_dir + or not inc_dir.is_relative_to(proj_dir) + or inc_dir.is_relative_to(src_dir) + or any(inc_dir.is_relative_to(proj_dir / name) for name in _SKIPPED_DIRS) + ): + continue + for local in sorted(inc_dir.rglob("*")): + if local.suffix in _HEADER_SUFFIXES and local.is_file(): + digest.update(str(local.relative_to(proj_dir)).encode()) + digest.update(local.read_bytes()) + digest.update(b"\0") + checksum = digest.hexdigest() + + # The ccache .sum file doubles as the freshness stamp + if not ( + gch.is_file() + and sum_path.is_file() + and sum_path.read_text(encoding="utf-8").strip() == checksum + ): + header.write_text(_GUARD_TEXT, encoding="utf-8") + source.write_text(content, encoding="utf-8") + result = subprocess.run( # noqa: PLW1510 + [cxx, "-x", "c++-header", *flags, "-c", str(source), "-o", str(gch)], + cwd=proj_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"ESPHome: could not compile the precompiled header:\n{result.stderr}" + ) + sum_path.write_text(checksum + "\n", encoding="utf-8") + + # Prepended: GCC only loads a .gch for the first -include + for src_env in src_envs: + src_env.Prepend(CXXFLAGS=_CONSUMER_FLAGS) + print( + "ESPHome: Compiling with a precompiled header " + "(set ESPHOME_PCH_ENABLE=0 to disable)" + ) + + +_setup_pch() diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 2322b37dfc3..6a906a42d06 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -286,6 +286,14 @@ def copy_ccache_script() -> None: ) +def copy_pch_script() -> None: + """Copy the precompiled header SCons script into the build dir.""" + copy_file_if_changed( + Path(__file__).parent / "pch.py.script", + CORE.relative_build_path("pch.py"), + ) + + def default_libdeps_dir() -> str: """The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package prefetch must resolve installed libraries against the same dir.""" @@ -327,6 +335,11 @@ def run_platformio_cli(*args, **kwargs) -> str | int: base_env = kwargs.pop("env", None) env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) + if CORE.is_configured: + from esphome.build_helpers.pch import ccache_pch_env, pch_script_enabled + + if pch_script_enabled(): + env.update(ccache_pch_env()) # The runner offers the out-of-flash tip but has no configured CORE, so # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same # variable; clear an inherited one so it cannot reach the wrong build. diff --git a/esphome/writer.py b/esphome/writer.py index bdd60af1dc9..9f2ff85cff5 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -610,6 +610,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): if idf_path.is_dir(): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) + # The PlatformIO pch files live in the project root + from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES + + for name in PCH_ARTIFACT_NAMES: + CORE.relative_build_path(name).unlink(missing_ok=True) # idedata caches live under the data dir, not the build path; globbed # so a future backend suffix cannot drift out of clean-all diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 20acbe302c1..935265a0042 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from esphome.build_gen import platformio +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM from esphome.core import CORE @@ -170,6 +171,40 @@ def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(CORE, "build_flags", set()) monkeypatch.setattr(CORE, "build_unflags", set()) monkeypatch.setattr(CORE, "cmake_args", {}) + # A platform that does not take the pch script + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "nrf52"}) + + +def test_pch_script_is_registered_and_copied( + clean_core: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """One place registers the pch script for every PlatformIO platform + that takes it.""" + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "esp8266"}) + monkeypatch.setattr(CORE, "build_path", tmp_path) + + platformio.write_project() + + assert "post:pch.py" in (tmp_path / "platformio.ini").read_text() + assert (tmp_path / "pch.py").is_file() + + +@pytest.mark.parametrize("disabled_by", ["platform", "knob"]) +def test_pch_script_is_left_out( + clean_core: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + disabled_by: str, +) -> None: + if disabled_by == "knob": + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: "esp8266"}) + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + monkeypatch.setattr(CORE, "build_path", tmp_path) + + platformio.write_project() + + assert "pch.py" not in (tmp_path / "platformio.ini").read_text() + assert not (tmp_path / "pch.py").exists() def test_get_ini_content_pins_cpp_standard( diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 99109bec5e3..0820da5be36 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -9,6 +9,8 @@ from unittest.mock import patch import pytest from esphome.build_helpers import pch +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE def _write(src_dir: Path, name: str, content: str) -> None: @@ -107,6 +109,20 @@ def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) +@pytest.mark.parametrize("platform", ["esp8266"]) +def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + assert pch.pch_script_enabled() + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert not pch.pch_script_enabled() + + +@pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_EXCLUDED_PLATFORMS)) +def test_pch_script_excluded_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + assert not pch.pch_script_enabled() + + def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None: """An angle include resolving under src/ must enter the digest; one that does not simply ends the walk.""" diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py new file mode 100644 index 00000000000..bccfc17477b --- /dev/null +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -0,0 +1,383 @@ +"""Tests for esphome/platformio/pch.py.script against a fake SCons env.""" + +from __future__ import annotations + +from collections.abc import Callable +import os +from pathlib import Path +import stat +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import pch +from esphome.platformio import toolchain + +pytestmark = pytest.mark.skipif( + os.name == "nt", reason="the fake compiler is a POSIX shell script" +) + +_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script" + + +class _FakePlatform: + packages = {"framework-x": {}, "toolchain-y": {}} + + def get_package_version(self, name: str) -> str | None: + # None for an optional package that is not installed + return None if name == "toolchain-y" else "1.2.3" + + +class _FakeSConsEnv(dict): + """Just enough of a SCons construction environment for pch.py.""" + + def __init__( + self, + proj_dir: Path, + src_dir: Path, + cxx: str, + flags: list[str], + platform_cls: type[_FakePlatform] = _FakePlatform, + ): + super().__init__(ENV={}) + self._subst = { + "$PROJECT_DIR": str(proj_dir), + "$PROJECT_SRC_DIR": str(src_dir), + "$CXX": cxx, + } + self._flags = flags + self._platform_cls = platform_cls + self.prepended: list[str] = [] + + def subst(self, expr: str) -> str: # noqa: N802 + return self._subst[expr] + + def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802 + return [self._flags] + + def PioPlatform(self) -> _FakePlatform: # noqa: N802 + return self._platform_cls() + + def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803 + self.prepended = CXXFLAGS + + def Flatten(self, nodes: list) -> list: # noqa: N802 + return nodes + + def GetProjectOption(self, name: str, default: list) -> list: # noqa: N802 + return self.get(f"option:{name}", default) + + +def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: + """A compiler stand-in that records its argv and writes the -o target.""" + cxx = tmp_path / "fake-gxx" + body = ( + 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' + ) + if fail: + body += "echo boom >&2\nexit 1\n" + else: + body += 'out=""; prev=""\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done\n' + body += '[ -n "$out" ] && echo gch > "$out"\n' + cxx.write_text("#!/bin/sh\n" + body) + cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC) + return cxx + + +def _run_script( + tmp_path: Path, + flags: list[str] | None = None, + fail: bool = False, + env_vars: dict[str, str] | None = None, + name: str = "dev", + platform_cls: type[_FakePlatform] = _FakePlatform, + build_files: Callable[[tuple], list] | None = None, +) -> _FakeSConsEnv: + proj = tmp_path / name + src = proj / "src" + (src / "esphome" / "core").mkdir(parents=True, exist_ok=True) + (src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n") + (src / "esphome" / "core" / "pch_prefix.h").write_text( + '#include "esphome/core/defines.h"\n' + ) + cxx = _fake_cxx(tmp_path, fail=fail) + args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) + # Distinct objects: the -include flags must land on projenv only + global_env = _FakeSConsEnv(*args) + projenv = _FakeSConsEnv(*args) + projenv.global_env = global_env + if build_files is not None: + global_env["PIOBUILDFILES"] = build_files(args) + source = _SCRIPT.read_text() + with patch.dict(os.environ, env_vars or {}, clear=True): + exec( # noqa: S102 + compile(source, "pch.py", "exec"), + { + "Import": lambda *_names: None, + "env": global_env, + "projenv": projenv, + "COMMAND_LINE_TARGETS": [], + }, + ) + return projenv + + +def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None: + scons_env = _run_script(tmp_path) + proj = tmp_path / "dev" + assert (proj / "esphome_pch_src.h").read_text() == pch.pch_header_text( + pch.PCH_DEFAULT_HEADERS + ) + assert (proj / "esphome_pch.h").read_text() == pch.PCH_GUARD_TEXT + assert (proj / "esphome_pch.h.gch").is_file() + assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64 + assert scons_env.prepended == pch.pch_consumer_flags() + # The -include flags are scoped to projenv (src compiles) + assert scons_env.global_env.prepended == [] + + +def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: + """The script cannot import esphome, so its copies are pinned.""" + namespace: dict[str, object] = { + "Import": lambda *_names: None, + "env": _FakeSConsEnv(tmp_path, tmp_path, "g++", []), + "projenv": None, + "COMMAND_LINE_TARGETS": ["nobuild"], + } + exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102 + assert namespace["_HEADER_NAME"] == pch.PCH_HEADER_NAME + assert namespace["_SOURCE_NAME"] == pch.PCH_SOURCE_NAME + assert namespace["_DEFAULT_HEADERS"] == pch.PCH_DEFAULT_HEADERS + assert namespace["_CONSUMER_FLAGS"] == pch.pch_consumer_flags() + assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT + assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern + + +def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: + """The pch holds only ESPHome's own headers: a failure is a defect.""" + with pytest.raises(RuntimeError, match="could not compile") as raised: + _run_script(tmp_path, fail=True) + assert "boom" in str(raised.value) + assert not (tmp_path / "dev" / "esphome_pch.h.gch.sum").exists() + + +def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None: + """One SCons element stays one compiler argv; -include pairs are + stripped from the .gch compile.""" + spaced = tmp_path / "My Configs" + spaced.mkdir() + (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "dev" / "src" / "other.h").write_text("") + flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"] + _run_script(tmp_path, flags=flags) + calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n") + gch_call = next(c for c in calls if "c++-header" in c).splitlines() + assert '-DUSB_PRODUCT="Pico 2W"' in gch_call + assert str(spaced) in gch_call + assert "-include" not in gch_call + # The stripped src-resolvable -include is folded into the prefix header + pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert pch.splitlines()[0] == '#include "other.h"' + + +def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None: + """-includefoo.h folds like the separated form, matching the native path.""" + (tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "dev" / "src" / "other.h").write_text("") + _run_script(tmp_path, flags=["-DX=1", "-includeother.h"]) + pch = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert pch.splitlines()[0] == '#include "other.h"' + + +def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None: + """Regression: identical configs in different dirs share cache keys.""" + sums = [] + for name in ("dev_a", "dev_b"): + proj = tmp_path / name + _run_script( + tmp_path, + flags=["-DX=1", "-I", str(proj / "include")], + env_vars={"CCACHE_BASEDIR": str(proj)}, + name=name, + ) + sums.append((proj / "esphome_pch.h.gch.sum").read_text()) + (tmp_path / "fake-gxx").unlink() + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + assert sums[0] == sums[1] + + +def test_pch_script_uses_the_envs_of_existing_src_objects(tmp_path: Path) -> None: + """The ESP-IDF builder creates the src objects from its own environments + before this script runs; flags on projenv would never reach them.""" + made: list[_FakeSConsEnv] = [] + + def build_files(args: tuple) -> list: + src = args[1] + made.extend(_FakeSConsEnv(*args) for _ in range(3)) + return [ + SimpleNamespace(env=made[0], sources=[src / "main.cpp"]), + SimpleNamespace(env=made[0], sources=[src / "esphome" / "a.cpp"]), + # C and framework objects keep their environments untouched + SimpleNamespace(env=made[1], sources=[src / "esphome" / "b.c"]), + SimpleNamespace(env=made[2], sources=[src.parent / "lib" / "c.cpp"]), + ] + + projenv = _run_script(tmp_path, build_files=build_files) + assert made[0].prepended == pch.pch_consumer_flags() + assert made[1].prepended == made[2].prepended == projenv.prepended == [] + + +def test_copy_pch_script(tmp_path: Path) -> None: + from esphome.core import CORE + + CORE.build_path = tmp_path + toolchain.copy_pch_script() + assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text() + + +def _run_without_projenv(tmp_path: Path, targets: list[str]) -> Path: + proj = tmp_path / "dev" + (proj / "src").mkdir(parents=True) + + def strict_import(*names: str) -> None: + if "projenv" in names: + raise RuntimeError("Import of non-existent variable 'projenv'") + + env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"]) + exec( # noqa: S102 + compile(_SCRIPT.read_text(), "pch.py", "exec"), + {"Import": strict_import, "env": env, "COMMAND_LINE_TARGETS": targets}, + ) + return proj + + +def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None: + """-t nobuild never exports projenv; the script must not abort.""" + proj = _run_without_projenv(tmp_path, ["nobuild"]) + assert not (proj / "esphome_pch.h").exists() + + +def test_pch_script_build_without_projenv_stops(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="no project environment"): + _run_without_projenv(tmp_path, []) + + +def test_pch_script_stops_when_no_source_object_matches(tmp_path: Path) -> None: + """Flags on projenv would reach no compile while the log says the + precompiled header is in use.""" + + def build_files(args: tuple) -> list: + lib = args[1].parent / "lib" / "c.cpp" + return [SimpleNamespace(env=_FakeSConsEnv(*args), sources=[lib])] + + with pytest.raises(RuntimeError, match="no C\\+\\+ source takes"): + _run_script(tmp_path, build_files=build_files) + + +def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None: + """.piolibdeps and non-header files must not enter the digest (or be + read at all); package versions already cover library identity.""" + proj = tmp_path / "dev" + libdeps = proj / ".piolibdeps" / "lib" / "src" + libdeps.mkdir(parents=True) + (libdeps / "lib.h").write_text("#define A 1\n") + override = proj / "lwip_override" + override.mkdir(parents=True) + (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") + (override / "notes.txt").write_text("v1\n") + flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + (libdeps / "lib.h").write_text("#define A 2\n") + (override / "notes.txt").write_text("v2\n") + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() == first + + +def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None: + """Generated headers in project-local -I dirs (e.g. rp2's lwip_override) + must invalidate the checksum when they change.""" + proj = tmp_path / "dev" + override = proj / "lwip_override" + override.mkdir(parents=True) + (override / "lwipopts.h").write_text("#define TCP_MSS 1460\n") + flags = ["-DX=1", "-I", str(override)] + _run_script(tmp_path, flags=flags) + first = (proj / "esphome_pch.h.gch.sum").read_text() + (override / "lwipopts.h").write_text("#define TCP_MSS 536\n") + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + assert (proj / "esphome_pch.h.gch.sum").read_text() != first + + +def test_pch_script_folds_force_includes_found_on_the_include_path( + tmp_path: Path, +) -> None: + """A framework force-include (Arduino.h) is read before the core + headers, as it was without the precompiled header.""" + _run_script(tmp_path, flags=["-DX=1", "-include", "Arduino.h"]) + source = (tmp_path / "dev" / "esphome_pch_src.h").read_text() + assert source.splitlines() == [ + '#include "Arduino.h"', + '#include "esphome/core/pch_prefix.h"', + ] + + +def test_pch_script_skipped_for_an_absolute_force_include( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + outside = tmp_path / "outside.h" + outside.write_text("") + scons_env = _run_script(tmp_path, flags=["-DX=1", "-include", str(outside)]) + assert scons_env.prepended == [] + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert "prevents the precompiled header" in capsys.readouterr().out + + +def _sum_after(tmp_path: Path, flags: list[str]) -> str: + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + _run_script(tmp_path, flags=flags) + return (tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text() + + +@pytest.mark.parametrize( + "option", [["-Iinclude"], ["-isystem", "include"], ["-iquoteinclude"]] +) +def test_pch_script_hashes_relative_include_dirs( + tmp_path: Path, option: list[str] +) -> None: + """Compiles run in the project root, so -Iinclude is a project dir.""" + include = tmp_path / "dev" / "include" + include.mkdir(parents=True) + (include / "user.h").write_text("#define A 1\n") + flags = ["-DX=1", *option, "-I."] + first = _sum_after(tmp_path, flags) + (include / "user.h").write_text("#define A 2\n") + assert _sum_after(tmp_path, flags) != first + + +def test_pch_script_hashes_the_sdkconfig(tmp_path: Path) -> None: + """sdkconfig.h is build output; the configuration it comes from is not.""" + proj = tmp_path / "dev" + proj.mkdir() + (proj / "sdkconfig.dev").write_text("CONFIG_X=y\n") + first = _sum_after(tmp_path, ["-DX=1"]) + (proj / "sdkconfig.dev").write_text("CONFIG_X=n\n") + assert _sum_after(tmp_path, ["-DX=1"]) != first + + +def test_pch_script_hashes_the_library_versions(tmp_path: Path) -> None: + sums = [] + for version in ("7.4.1", "7.4.2"): + (tmp_path / "fake-gxx.argv").unlink(missing_ok=True) + + with patch.object( + _FakeSConsEnv, + "GetProjectOption", + lambda self, name, default, version=version: [f"ArduinoJson@{version}"], + ): + _run_script(tmp_path) + sums.append((tmp_path / "dev" / "esphome_pch.h.gch.sum").read_text()) + assert sums[0] != sums[1] diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 6138d745248..d218f3201f5 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -330,6 +330,30 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: _ = toolchain.IDEData({"extra": None}).extra_flash_images +@pytest.mark.parametrize( + ("platform", "expected"), [("esp8266", "true"), ("nrf52", None)] +) +def test_run_platformio_cli_exports_the_pch_ccache_settings( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + expected: str | None, +) -> None: + """Only for a platform that takes the pch script.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: "arduino", + } + + with patch.dict(os.environ, {}, clear=True): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get("CCACHE_PCH_EXTSUM") == expected + + @pytest.mark.parametrize( ("platform", "framework", "expected"), [ diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 7fd03062a27..432a2065bff 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -677,6 +677,29 @@ def test_clean_build_partial_exists( assert "dependencies.lock" not in caplog.text +@patch("esphome.writer.CORE") +def test_clean_build_partial_removes_pch_artifacts( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """The PlatformIO pch sidecars live at the project root and must go in + a partial clean, like the native backend's under .pioenvs.""" + from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES as names + + assert "esphome_pch.h.gch" in names + for name in names: + (tmp_path / name).write_text("x") + mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs" + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.side_effect = lambda name: tmp_path / name + mock_core.relative_internal_path.side_effect = tmp_path.joinpath + + clean_build() + + for name in names: + assert not (tmp_path / name).exists() + + @patch("esphome.writer.CORE") def test_clean_build_nothing_exists( mock_core: MagicMock, From 4ec8fae5dfa791a01049e6007fe36d6473f8ae17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 19:26:51 +0200 Subject: [PATCH 667/759] [rp2] Enable the PlatformIO precompiled header (#19824) --- esphome/build_helpers/pch.py | 2 -- tests/unit_tests/build_helpers/test_pch.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 7889978f066..25dc9a7ae18 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -16,7 +16,6 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.helpers import write_file_if_changed @@ -54,7 +53,6 @@ PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2, PLATFORM_RTL87XX, } ) diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 0820da5be36..9ebcbe604bb 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -109,7 +109,7 @@ def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) -@pytest.mark.parametrize("platform", ["esp8266"]) +@pytest.mark.parametrize("platform", ["esp8266", "rp2"]) def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} assert pch.pch_script_enabled() From d31ffd4ca3dcbc5ce0f2be5f809df522d14601a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 19:41:59 +0200 Subject: [PATCH 668/759] [esp32] Enable the precompiled header on the PlatformIO toolchain (#19825) --- esphome/build_helpers/pch.py | 2 -- script/determine-jobs.py | 10 ++++++---- tests/script/test_determine_jobs.py | 6 ++++++ tests/unit_tests/build_helpers/test_pch.py | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index 25dc9a7ae18..dc23942c8e8 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -13,7 +13,6 @@ import re from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env from esphome.const import ( PLATFORM_BK72XX, - PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, PLATFORM_RTL87XX, @@ -50,7 +49,6 @@ PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( { PLATFORM_BK72XX, - PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, PLATFORM_RTL87XX, diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 3f0143076b2..e3a0ff688fb 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -546,10 +546,6 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ( *_SMOKE_HARNESS_TRIGGER_PATH_PREFIXES, ) -# Standalone files that, when changed, trigger the PlatformIO compile test: -# - esphome/build_gen/platformio.py -- the PlatformIO build generator -# - script/test_build_components.py -- the harness the job invokes -# - .github/workflows/ci.yml -- the job's own definition # Shared by every toolchain smoke-test job: the harness it invokes and the # workflow that defines it _SMOKE_HARNESS_TRIGGER_FILES = frozenset( @@ -559,8 +555,14 @@ _SMOKE_HARNESS_TRIGGER_FILES = frozenset( } ) +# Standalone files that, when changed, trigger the PlatformIO compile test ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | { + # The PlatformIO build generator "esphome/build_gen/platformio.py", + # Decides which platforms take the pch script in esphome/platformio/, + # and the module that parses its switch and the ccache settings + "esphome/build_helpers/pch.py", + "esphome/build_helpers/ccache.py", } diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index c3f30b8ed17..49f101d6792 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1089,6 +1089,9 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [ # PlatformIO subsystem (path-prefix trigger) + build generator ["esphome/platformio/runner.py"], ["esphome/platformio/toolchain.py"], + # Decides which platforms take the pch script + ["esphome/build_helpers/pch.py"], + ["esphome/build_helpers/ccache.py"], ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], @@ -1150,6 +1153,9 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the # full list -- only esphome/build_gen/platformio.py is a trigger. (["esphome/build_gen/espidf.py"], [], []), + # The rest of build_helpers/ is not a trigger. + (["esphome/build_helpers/size_summary.py"], [], []), + (["esphome/build_helpers/ninja.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 9ebcbe604bb..a64e9525e67 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -109,7 +109,7 @@ def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) -@pytest.mark.parametrize("platform", ["esp8266", "rp2"]) +@pytest.mark.parametrize("platform", ["esp32", "esp8266", "rp2"]) def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} assert pch.pch_script_enabled() From 45189ef44564cfd39571ce2d24257fb19be3f093 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 19:49:54 +0200 Subject: [PATCH 669/759] [nrf52] Precompile the core headers on the sdk-nrf build (#19826) --- esphome/components/nrf52/__init__.py | 71 ++++++++++ tests/unit_tests/components/nrf52/test_pch.py | 134 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tests/unit_tests/components/nrf52/test_pch.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 0d1cfd6c1e0..488984ff70d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,7 @@ import shutil import subprocess from esphome import pins +from esphome.build_helpers import pch import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -781,6 +782,67 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +# GCC only loads a precompiled header ahead of every other forced header, and +# Zephyr forces two with -imacros. They hold macros only, so the C++ sources +# of the app get them through the precompiled header. +_PCH_CMAKE_LINES = [ + "", + "# ESPHome precompiled header", + "get_property(esphome_options TARGET zephyr_interface", + " PROPERTY INTERFACE_COMPILE_OPTIONS)", + "set(esphome_kept_options)", + "set(esphome_pch_headers)", + "foreach(option IN LISTS esphome_options)", + ' if(option MATCHES "imacros> (.+)$")', + ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', + " list(APPEND esphome_kept_options", + ' "$<$,$,app>>>:${option}>")', + " else()", + ' list(APPEND esphome_kept_options "${option}")', + " endif()", + "endforeach()", + "if(NOT esphome_pch_headers)", + ' message(FATAL_ERROR "ESPHome: the headers Zephyr forces were not found, so "', + ' "the precompiled header would not load (set ESPHOME_PCH_ENABLE=0)")', + "endif()", + "set_property(TARGET zephyr_interface", + ' PROPERTY INTERFACE_COMPILE_OPTIONS "${esphome_kept_options}")', + *( + f'list(APPEND esphome_pch_headers "${{CMAKE_CURRENT_LIST_DIR}}/../src/{header}")' + for header in pch.PCH_DEFAULT_HEADERS + ), + 'list(TRANSFORM esphome_pch_headers REPLACE "(.+)" "$<$:\\\\1>")', + "target_precompile_headers(app PRIVATE ${esphome_pch_headers})", +] +# Where CMake puts the .gch of the app, below its binary dir +_PCH_SUM_PATH = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: + """Write the checksum ccache reads in place of the .gch. The app binary + dir only exists after the first configure; sysbuild nests it.""" + app_dir = build_dir / "zephyr" + if not (app_dir / "CMakeCache.txt").is_file(): + app_dir = build_dir + if not (app_dir / "CMakeCache.txt").is_file(): + return + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + zephyr_data()[KEY_BOARD], + # What the Zephyr configuration is generated from + *( + path.read_text(encoding="utf-8") + for path in sorted(source_dir.iterdir()) + if path.suffix in (".conf", ".overlay") + ), + ), + ) + write_file_if_changed(app_dir / _PCH_SUM_PATH, checksum + "\n") + + def _generate_cmake_lists() -> bool: """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() @@ -824,6 +886,9 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch.pch_enabled(): + lines += _PCH_CMAKE_LINES + if link_flags: lines += [ "", @@ -897,6 +962,12 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) + if pch.pch_enabled(): + pch.log_pch_in_use() + # Zephyr turns ccache on by itself when it is installed + env.update(pch.ccache_pch_env()) + _write_pch_checksum(build_dir, source_dir) + west_cmd = _west_build_command( paths["python_executable"], board, build_dir, source_dir ) diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py new file mode 100644 index 00000000000..0052df07720 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -0,0 +1,134 @@ +"""nrf52 sdk-nrf precompiled header: the CMake block and the ccache checksum.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from esphome.components import nrf52 +from esphome.components.zephyr.const import KEY_BOARD +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain +from esphome.core import CORE, EsphomeError + +SUM = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _generate_cmake(tmp_path: Path) -> str: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + with ( + patch( + "esphome.components.zephyr.library.generate_zephyr_modules", + return_value=[], + ), + patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]), + patch.object(nrf52, "get_project_link_flags", return_value=[]), + ): + nrf52._generate_cmake_lists() + return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() + + +def test_cmake_lists_precompile_the_core_headers(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path) + assert "target_precompile_headers(app PRIVATE ${esphome_pch_headers})" in text + assert "/../src/esphome/core/pch_prefix.h" in text + # The headers Zephyr forces leave the C++ compiles of the app only + assert ( + "$<$," + "$,app>>>:${option}>" + ) in text + assert r'REPLACE "(.+)" "$<$:\\1>"' in text + # A Zephyr that spells them another way must not go unnoticed + assert "if(NOT esphome_pch_headers)\n message(FATAL_ERROR" in text + + +def test_cmake_lists_pch_block_disabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + text = _generate_cmake(tmp_path) + assert "precompile" not in text + assert "zephyr_interface" not in text + + +def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: + """Write the checksum for a build dir whose app image sits in ``app``.""" + CORE.build_path = tmp_path + header = tmp_path / "src" / "esphome" / "core" / "pch_prefix.h" + header.parent.mkdir(parents=True, exist_ok=True) + header.write_text("#define M 1\n") + source_dir = tmp_path / "zephyr" + source_dir.mkdir(exist_ok=True) + (source_dir / "prj.conf").write_text(conf) + (source_dir / "CMakeLists.txt").write_text("not part of the checksum\n") + build_dir = tmp_path / ".pioenvs" / "livingroom" + (build_dir / app).mkdir(parents=True, exist_ok=True) + (build_dir / app / "CMakeCache.txt").write_text("") + with ( + patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + ): + nrf52._write_pch_checksum(build_dir, source_dir) + return build_dir / app / SUM + + +@pytest.mark.parametrize("app", ["zephyr", "."]) +def test_pch_checksum_is_written_next_to_the_gch(tmp_path: Path, app: str) -> None: + """Sysbuild nests the app image; without it the build dir is the app.""" + sum_path = _write_checksum(tmp_path, app) + assert len(sum_path.read_text().strip()) == 64 + + +def test_pch_checksum_tracks_the_zephyr_configuration(tmp_path: Path) -> None: + first = _write_checksum(tmp_path, "zephyr").read_text() + assert _write_checksum(tmp_path, "zephyr", "CONFIG_X=n\n").read_text() != first + + +def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None: + CORE.build_path = tmp_path + build_dir = tmp_path / ".pioenvs" / "livingroom" + nrf52._write_pch_checksum(build_dir, tmp_path / "zephyr") + assert not build_dir.exists() + + +@pytest.fixture +def run_cmd(tmp_path: Path) -> Mock: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + CORE.toolchain = Toolchain.SDK_NRF + with ( + patch.object(nrf52, "check_and_install"), + patch.object(nrf52, "_generate_cmake_lists", return_value=False), + patch.object( + nrf52, + "get_build_paths", + return_value={"python_executable": "python3", "framework_path": tmp_path}, + ), + patch.object(nrf52, "get_build_env", return_value={}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + patch.object(nrf52, "run_command_ok", return_value=False) as run, + ): + yield run + + +def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: + # clear=True also drops ambient CCACHE_* and ESPHOME_PCH_* overrides + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(EsphomeError, match="nRF52 native build failed"), + ): + nrf52.run_compile(None, {}) + env = run_cmd.call_args.kwargs["env"] + assert env["CCACHE_PCH_EXTSUM"] == "true" + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + + +def test_disabled_leaves_the_west_environment_alone( + run_cmd: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + nrf52.run_compile(None, {}) + assert "CCACHE_PCH_EXTSUM" not in run_cmd.call_args.kwargs["env"] From 025cf1f63835b18c463604ed7b27ef4ecd716772 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:50:10 +0200 Subject: [PATCH 670/759] Bump bundled esphome-device-builder to 1.17.2 (#19848) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 079ebef1947..4c82b8eba9a 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.17.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.17.2 RUN \ platformio settings set enable_telemetry No \ From fc803fcc6d771c0b0e606d6fa13177b25dcd5c77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 20:06:53 +0200 Subject: [PATCH 671/759] [libretiny] Enable the PlatformIO precompiled header on Linux hosts (#19827) --- esphome/build_helpers/pch.py | 16 ++++++++++++++-- tests/unit_tests/build_helpers/test_pch.py | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index dc23942c8e8..bd13726d85f 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -9,6 +9,7 @@ import os from pathlib import Path import posixpath import re +import sys from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env from esphome.const import ( @@ -47,10 +48,18 @@ PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) # PlatformIO platforms that do not take the pch script PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( + { + PLATFORM_NRF52, + } +) + +# The GCC 10.3 of LibreTiny only loads its own .gch back on Linux: elsewhere +# the compiler is loaded at a new address each run, which GCC accepts from +# version 12. Remove once LibreTiny ships GCC 12 or newer +PCH_SCRIPT_LINUX_ONLY_PLATFORMS = frozenset( { PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_NRF52, PLATFORM_RTL87XX, } ) @@ -94,7 +103,10 @@ def pch_script_enabled() -> bool: """Whether this PlatformIO build takes the pch script.""" from esphome.core import CORE - return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS + platform = CORE.target_platform + if platform in PCH_SCRIPT_LINUX_ONLY_PLATFORMS and sys.platform != "linux": + return False + return pch_enabled() and platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS def pch_header_text(include_headers: Iterable[str]) -> str: diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index a64e9525e67..0ea82157381 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -109,14 +109,27 @@ def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"]) -@pytest.mark.parametrize("platform", ["esp32", "esp8266", "rp2"]) +@pytest.mark.parametrize( + "platform", ["bk72xx", "esp32", "esp8266", "ln882x", "rp2", "rtl87xx"] +) def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pch.sys, "platform", "linux") CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} assert pch.pch_script_enabled() monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") assert not pch.pch_script_enabled() +@pytest.mark.parametrize("host", ["darwin", "win32"]) +@pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_LINUX_ONLY_PLATFORMS)) +def test_pch_script_linux_only_platform( + platform: str, host: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(pch.sys, "platform", host) + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + assert not pch.pch_script_enabled() + + @pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_EXCLUDED_PLATFORMS)) def test_pch_script_excluded_platform(platform: str) -> None: CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} From 9e8044868c4260df3f753f6a15ba373c897f13b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:30:32 -0400 Subject: [PATCH 672/759] Bump filelock from 4.0.1 to 4.0.3 (#19852) 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 81f3cbcdd2d..15c14642b37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.12 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.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.3 From 98754bedcd496e9b6925dbb3b33d5a13116b7432 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:30:51 -0400 Subject: [PATCH 673/759] Bump pylint from 4.0.8 to 4.0.9 (#19853) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 95e05d1be9c..5331c64a6b7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.8 +pylint==4.0.9 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py From eb97d15f2ec801e166c5fb9b8ae0c7ce5b0ba047 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:31:05 -0400 Subject: [PATCH 674/759] Bump github/codeql-action/init from 4.38.1 to 4.38.2 (#19855) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 efa6ccf5149..e26a006979d 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@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 45f8dbb4da6ca47cc1a91eec79e76d7593567f51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:31:37 -0400 Subject: [PATCH 675/759] Bump platformdirs from 4.11.12 to 4.11.14 (#19854) 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 15c14642b37..b62cd712a70 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.12 # native esp-idf toolchain global cache dir +platformdirs==4.11.14 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 7e7035db729cc829515033dc819c3e40c060529d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:31:53 -0400 Subject: [PATCH 676/759] Bump github/codeql-action/analyze from 4.38.1 to 4.38.2 (#19856) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 e26a006979d..da02a5dd09f 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@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: "/language:${{matrix.language}}" From 728449f69d380f6787c02862e0d24f5830bf3df7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:32:15 -0400 Subject: [PATCH 677/759] Bump ruff from 0.16.8 to 0.16.9 (#19851) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0241857688..b8f06eb0143 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.8 + rev: v0.16.9 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 5331c64a6b7..947d8c79c7b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.9 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.8 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.9 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 8cbc18d040d3c026e8f66dd17ac83ddd1e58f68d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:43:38 -0400 Subject: [PATCH 678/759] Bump flake8 from 7.3.0 to 7.4.1 (#19850) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8f06eb0143..48c0d1d6fd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 additional_dependencies: diff --git a/requirements_test.txt b/requirements_test.txt index 947d8c79c7b..7f6d75f9470 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ pylint==4.0.9 -flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.9 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.3 # .github/workflows/ci.yml reads this pin From 13c27429a4d54d1b2ff9fd611f9baafdc3e5f37a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:30:29 +1000 Subject: [PATCH 679/759] [split_buffer] Allow direct access to buffer chunks (#19819) --- .../components/split_buffer/split_buffer.cpp | 39 +++- .../components/split_buffer/split_buffer.h | 11 +- .../split_buffer/split_buffer_test.cpp | 213 ++++++++++++++++++ 3 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 tests/components/split_buffer/split_buffer_test.cpp diff --git a/esphome/components/split_buffer/split_buffer.cpp b/esphome/components/split_buffer/split_buffer.cpp index 526a19c71cc..63a0f0d9195 100644 --- a/esphome/components/split_buffer/split_buffer.cpp +++ b/esphome/components/split_buffer/split_buffer.cpp @@ -1,5 +1,8 @@ #include "split_buffer.h" +#include +#include + #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -8,15 +11,14 @@ static constexpr const char *const TAG = "split_buffer"; SplitBuffer::~SplitBuffer() { this->free(); } -bool SplitBuffer::init(size_t total_length) { +bool SplitBuffer::init(size_t total_length, size_t max_buffer_size) { this->free(); // Clean up any existing allocation - if (total_length == 0) { + if (total_length == 0 || max_buffer_size == 0) { return false; } - this->total_length_ = total_length; - size_t current_buffer_size = total_length; + size_t current_buffer_size = std::min(total_length, max_buffer_size); RAMAllocator ptr_allocator; RAMAllocator allocator; @@ -63,6 +65,7 @@ bool SplitBuffer::init(size_t total_length) { this->buffers_ = temp_buffers; this->buffer_count_ = needed_buffers; this->buffer_size_ = current_buffer_size; + this->total_length_ = total_length; ESP_LOGD(TAG, "Allocated %zu * %zu bytes - %zu bytes", this->buffer_count_, this->buffer_size_, this->total_length_); return true; @@ -122,6 +125,34 @@ uint8_t &SplitBuffer::operator[](size_t index) { return const_cast(static_cast(this)->operator[](index)); } +const uint8_t *SplitBuffer::get_span(size_t index, size_t &length) const { + if (index >= this->total_length_) { + length = 0; + return nullptr; + } + const size_t offset = index % this->buffer_size_; + length = std::min(this->buffer_size_ - offset, this->total_length_ - index); + return this->buffers_[index / this->buffer_size_] + offset; +} + +uint8_t *SplitBuffer::get_span(size_t index, size_t &length) { + return const_cast(static_cast(this)->get_span(index, length)); +} + +void SplitBuffer::write(size_t index, const uint8_t *data, size_t length) { + while (length != 0) { + size_t span_length; + uint8_t *span = this->get_span(index, span_length); + if (span == nullptr) + return; + span_length = std::min(span_length, length); + memcpy(span, data, span_length); + index += span_length; + data += span_length; + length -= span_length; + } +} + /** * Fill the entire buffer with a single byte value * @param value Fill value diff --git a/esphome/components/split_buffer/split_buffer.h b/esphome/components/split_buffer/split_buffer.h index b615ddce74d..6f4ab35e78d 100644 --- a/esphome/components/split_buffer/split_buffer.h +++ b/esphome/components/split_buffer/split_buffer.h @@ -16,8 +16,8 @@ class SplitBuffer { SplitBuffer() = default; ~SplitBuffer(); - // Initialize the buffer with the desired total length - bool init(size_t total_length); + // Initialize the buffer with the desired total length; no sub-buffer will be larger than `max_buffer_size` + bool init(size_t total_length, size_t max_buffer_size = SIZE_MAX); // Free all allocated buffers void free(); @@ -27,6 +27,13 @@ class SplitBuffer { const uint8_t &operator[](size_t index) const; void fill(uint8_t value) const; + // Pointer to the byte at `index`; `length` is set to how many bytes are contiguous from there. + // Returns nullptr with `length` 0 if `index` is out of range. + const uint8_t *get_span(size_t index, size_t &length) const; + uint8_t *get_span(size_t index, size_t &length); + // Copy `length` bytes from `data` into the buffer starting at `index`; bytes past the end are dropped. + void write(size_t index, const uint8_t *data, size_t length); + // Get the total length size_t size() const { return this->total_length_; } diff --git a/tests/components/split_buffer/split_buffer_test.cpp b/tests/components/split_buffer/split_buffer_test.cpp new file mode 100644 index 00000000000..e9cdb12a3b7 --- /dev/null +++ b/tests/components/split_buffer/split_buffer_test.cpp @@ -0,0 +1,213 @@ +#include + +#include +#include +#include + +#include "esphome/components/split_buffer/split_buffer.h" + +namespace esphome::split_buffer::testing { + +static std::vector make_pattern(size_t length, uint8_t seed = 1) { + std::vector data(length); + for (size_t i = 0; i != length; i++) + data[i] = static_cast(seed + i); + return data; +} + +static std::vector read_all(const SplitBuffer &buffer) { + std::vector out(buffer.size()); + for (size_t i = 0; i != buffer.size(); i++) + out[i] = buffer[i]; + return out; +} + +TEST(SplitBufferInit, SingleBufferByDefault) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100)); + EXPECT_TRUE(buffer.is_valid()); + EXPECT_EQ(buffer.size(), 100u); + EXPECT_EQ(buffer.get_buffer_count(), 1u); +} + +TEST(SplitBufferInit, MaxBufferSizeSplits) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100, 32)); + EXPECT_EQ(buffer.size(), 100u); + EXPECT_EQ(buffer.get_buffer_count(), 4u); +} + +TEST(SplitBufferInit, ZeroLengthOrMaxFails) { + SplitBuffer buffer; + EXPECT_FALSE(buffer.init(0)); + EXPECT_FALSE(buffer.init(100, 0)); + EXPECT_FALSE(buffer.is_valid()); +} + +TEST(SplitBufferInit, StartsZeroed) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferFill, FillsShortLastBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + buffer.fill(0xA5); + EXPECT_EQ(read_all(buffer), std::vector(50, 0xA5)); +} + +TEST(SplitBufferGetSpan, SingleBufferCoversRest) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(100)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(0, length), &buffer[0]); + EXPECT_EQ(length, 100u); + EXPECT_EQ(buffer.get_span(99, length), &buffer[99]); + EXPECT_EQ(length, 1u); +} + +TEST(SplitBufferGetSpan, StopsAtSubBufferBoundary) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(0, length), &buffer[0]); + EXPECT_EQ(length, 16u); + EXPECT_EQ(buffer.get_span(10, length), &buffer[10]); + EXPECT_EQ(length, 6u); + EXPECT_EQ(buffer.get_span(15, length), &buffer[15]); + EXPECT_EQ(length, 1u); + EXPECT_EQ(buffer.get_span(16, length), &buffer[16]); + EXPECT_EQ(length, 16u); +} + +TEST(SplitBufferGetSpan, ShortLastBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 0; + EXPECT_EQ(buffer.get_span(48, length), &buffer[48]); + EXPECT_EQ(length, 2u); + EXPECT_EQ(buffer.get_span(49, length), &buffer[49]); + EXPECT_EQ(length, 1u); +} + +TEST(SplitBufferGetSpan, OutOfRange) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t length = 99; + EXPECT_EQ(buffer.get_span(50, length), nullptr); + EXPECT_EQ(length, 0u); + length = 99; + EXPECT_EQ(buffer.get_span(1000, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, ConstBufferGivesConstSpan) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const SplitBuffer &ref = buffer; + size_t length = 0; + const uint8_t *span = ref.get_span(20, length); + EXPECT_EQ(span, &ref[20]); + EXPECT_EQ(length, 12u); +} + +TEST(SplitBufferInit, FailedInitLeavesEmptyState) { + SplitBuffer buffer; + // One-byte pieces need a pointer array too large to allocate, so init fails straight away + EXPECT_FALSE(buffer.init(SIZE_MAX / 16, 1)); + EXPECT_EQ(buffer.size(), 0u); + size_t length = 99; + EXPECT_EQ(buffer.get_span(0, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, UninitializedReturnsNull) { + SplitBuffer buffer; + size_t length = 99; + EXPECT_EQ(buffer.get_span(0, length), nullptr); + EXPECT_EQ(length, 0u); +} + +TEST(SplitBufferGetSpan, SpansCoverWholeBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + size_t index = 0; + std::vector lengths; + while (index < buffer.size()) { + size_t length = 0; + ASSERT_NE(buffer.get_span(index, length), nullptr); + lengths.push_back(length); + index += length; + } + EXPECT_EQ(index, 50u); + EXPECT_EQ(lengths, (std::vector{16, 16, 16, 2})); +} + +TEST(SplitBufferWrite, WithinOneSubBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(5); + buffer.write(3, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.end(), expected.begin() + 3); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, AcrossSubBuffers) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(30); + buffer.write(10, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.end(), expected.begin() + 10); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, WholeBuffer) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(50); + buffer.write(0, data.data(), data.size()); + EXPECT_EQ(read_all(buffer), data); +} + +TEST(SplitBufferWrite, TruncatesPastEnd) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + // The source is sized to the full request so ASan catches any read beyond it. + const auto data = make_pattern(20); + buffer.write(40, data.data(), data.size()); + auto expected = std::vector(50, 0); + std::copy(data.begin(), data.begin() + 10, expected.begin() + 40); + EXPECT_EQ(read_all(buffer), expected); +} + +TEST(SplitBufferWrite, StartOutOfRangeIsIgnored) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + const auto data = make_pattern(5); + buffer.write(50, data.data(), data.size()); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferWrite, ZeroLengthIsNoop) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(50, 16)); + buffer.write(0, nullptr, 0); + EXPECT_EQ(read_all(buffer), std::vector(50, 0)); +} + +TEST(SplitBufferWrite, MatchesContiguousBuffer) { + // Each sub-buffer size, including ones that divide the total evenly, must give the same result. + const auto data = make_pattern(64, 7); + for (size_t max_size : {1u, 3u, 8u, 16u, 63u, 64u, 1000u}) { + SplitBuffer buffer; + ASSERT_TRUE(buffer.init(64, max_size)); + buffer.write(0, data.data(), 20); + buffer.write(20, data.data() + 20, 44); + EXPECT_EQ(read_all(buffer), data) << "max_buffer_size=" << max_size; + } +} + +} // namespace esphome::split_buffer::testing From 89cc689864718a1a29c691d8487be963deeb9cf6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:08:06 +0100 Subject: [PATCH 680/759] [esp32_hosted][esp32_ble] Support esp_hosted 3.x alongside 2.x, keep builds on 2.12.13 for now (#19621) --- esphome/components/esp32_ble/__init__.py | 9 +- esphome/components/esp32_ble/ble.cpp | 44 +++- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 6 +- .../esp32_ble_beacon/esp32_ble_beacon.h | 6 +- .../esp32_ble_server/ble_server.cpp | 2 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 6 +- esphome/components/esp32_hosted/__init__.py | 219 ++++++++++++++++-- .../update/esp32_hosted_update.cpp | 16 +- esphome/idf_component.yml | 2 +- 9 files changed, 259 insertions(+), 51 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 77ec17f3891..ef95243bc11 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -474,12 +474,17 @@ def final_validation(config: ConfigType) -> None: # Check if hosted bluetooth is being used if "esp32_hosted" in full_config: + from esphome.components.esp32_hosted import uses_esp_hosted_3x + add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) - add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) + if uses_esp_hosted_3x(): + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True) + else: + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) # Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client) has_ble_client = ( diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 81fa328c160..a44886b8c81 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -6,16 +6,21 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #else #include "esphome/components/watchdog/watchdog.h" #include extern "C" { #include +#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT #include #include +#endif } +#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT +#include +#endif #endif #include #include @@ -35,7 +40,7 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED // Bringing up the remote BT controller issues synchronous RPCs to the // co-processor with 5 second response timeouts, and the default task watchdog // is also 5 seconds. If the co-processor firmware does not answer (for example @@ -43,8 +48,9 @@ static const char *const TAG = "esp32_ble"; // device before the RPC could return an error, causing a boot loop. Raise the // watchdog for the duration of the bring-up so failures surface as error // returns instead. 60 seconds covers the worst case: transport reconnect -// (up to ~20s), version preflight (1s), controller init/enable (5s each) and -// the bluedroid host bring-up over the hosted HCI transport. +// (up to ~20s), version preflight (1s), the controller init retry window +// (5s of 5s RPCs), controller enable (5s) and the bluedroid host bring-up +// over the hosted HCI transport. static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; #endif @@ -202,10 +208,10 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); #endif -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_IDLE) { @@ -239,8 +245,7 @@ bool ESP32BLE::ble_setup_() { } // Fast preflight (1 second RPC timeout): verifies the co-processor answers - // RPCs at all before the 5 second timeout BT controller RPCs below, and - // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + // RPCs at all before the 5 second timeout BT controller RPCs below. esp_hosted_coprocessor_fwver_t fw_ver{}; if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " @@ -249,6 +254,19 @@ bool ESP32BLE::ble_setup_() { } ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); +#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT + esp_hosted_bt_host_stack_cfg_t bt_cfg{}; + bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID; + bt_cfg.bring_up_controller = true; + bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS; + if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) { + ESP_LOGE(TAG, + "BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); + return false; + } +#else if (esp_hosted_bt_controller_init() != ESP_OK) { ESP_LOGE(TAG, "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 @@ -273,6 +291,7 @@ bool ESP32BLE::ble_setup_() { .register_host_callback = hosted_hci_bluedroid_register_host_callback, }; esp_bluedroid_attach_hci_driver(&operations); +#endif #endif err = esp_bluedroid_init(); @@ -393,7 +412,7 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifdef CONFIG_BT_CONTROLLER_DISABLED // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); #endif @@ -416,7 +435,7 @@ bool ESP32BLE::ble_dismantle_() { ESP_LOGD(TAG, "Already deinitialized"); } -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_IDLE) { // stop bt controller if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) { @@ -440,6 +459,11 @@ bool ESP32BLE::ble_dismantle_() { return false; } } +#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT) + if (esp_hosted_bt_host_stack_teardown() != ESP_OK) { + ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed"); + return false; + } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index ab728f9f6fc..47e4b7ebb20 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -3,7 +3,7 @@ #ifdef USE_ESP32 -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -35,7 +35,7 @@ void ESP32BLEBeacon::dump_config() { } } *bpos = '\0'; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", @@ -81,7 +81,7 @@ void ESP32BLEBeacon::on_advertise_() { ibeacon_adv_data.ibeacon_vendor.measured_power = static_cast(this->measured_power_); esp_err_t err; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED ESP_LOGD(TAG, "Setting BLE TX power"); err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_); if (err != ESP_OK) { diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 986778de579..a7132a54652 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -5,7 +5,7 @@ #ifdef USE_ESP32 -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -47,7 +47,7 @@ class ESP32BLEBeacon final : public Component { void set_min_interval(uint16_t val) { this->min_interval_ = val; } void set_max_interval(uint16_t val) { this->max_interval_ = val; } void set_measured_power(int8_t val) { this->measured_power_ = val; } -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; } #endif void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); @@ -61,7 +61,7 @@ class ESP32BLEBeacon final : public Component { uint16_t min_interval_{}; uint16_t max_interval_{}; int8_t measured_power_{}; -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED esp_power_level_t tx_power_{}; #endif esp_ble_adv_params_t ble_adv_params_; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 45679b9b982..e34a57cf37f 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -10,7 +10,7 @@ #include #include #include -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e25b6f59fa0..9bb90026e3f 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include @@ -635,7 +635,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED if (force_ble && !this->coex_prefer_ble_) { ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); this->coex_prefer_ble_ = true; @@ -645,7 +645,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) { this->coex_prefer_ble_ = false; esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default } -#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#endif // CONFIG_BT_CONTROLLER_DISABLED } #endif diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index ad01e130eb9..d78941ea4c8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import re from esphome import pins from esphome.components import esp32 @@ -7,15 +8,22 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, + CONF_COMPONENTS, CONF_CS_PIN, + CONF_FRAMEWORK, CONF_FREQUENCY, CONF_MISO_PIN, CONF_MOSI_PIN, + CONF_NAME, + CONF_REF, CONF_RESET_PIN, CONF_TYPE, CONF_VARIANT, + KEY_ESP32, ) +from esphome.core import CORE from esphome.cpp_generator import add_define +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] @@ -23,6 +31,9 @@ DEPENDENCIES = ["esp32"] # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] +# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5) +ESP_HOSTED_COMPONENT = "espressif/esp_hosted" + CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" CONF_CMD_PIN = "cmd_pin" @@ -145,6 +156,34 @@ CONFIG_SCHEMA = cv.typed_schema( ) +# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x +_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$") + + +def user_esp_hosted_major() -> int | None: + """Major version of a user-pinned esp_hosted, or None without a pin.""" + try: + full_config = fv.full_config.get() + except LookupError: + full_config = CORE.config + esp32_config = full_config.get(KEY_ESP32) or {} + for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []): + if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT: + continue + ref = component.get(CONF_REF, "").strip() + if not ref: + return 2 + if match := _PINNED_MAJOR.match(ref): + return int(match.group(1)) + return 3 + return None + + +def uses_esp_hosted_3x() -> bool: + """Whether the user pinned esp_hosted 3.x.""" + return (major := user_esp_hosted_major()) is not None and major >= 3 + + 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 @@ -155,12 +194,34 @@ def _final_validate(config: ConfigType) -> None: "Remove the framework version from your configuration to use the " "recommended version, or pin a version at or above 5.3." ) + if (major := user_esp_hosted_major()) is None or major < 3: + return + if idf_ver < cv.Version(5, 5, 0): + raise cv.Invalid( + f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. " + f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework." + "components to stay on the 2.x line, or use ESP-IDF 5.5 or newer." + ) + if not config[CONF_ACTIVE_HIGH]: + raise cv.Invalid( + "esp_hosted 3.x always parks the reset line high with a low pulse, so " + "'active_high: false' cannot be expressed on it. Remove the " + f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " + "on the 2.x line." + ) + if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1: + raise cv.Invalid( + "esp_hosted 3.x cannot be built with a 1-bit SDIO bus " + "(espressif/esp-hosted-mcu#245). Remove the " + f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay " + "on the 2.x line, or use a 4-bit bus." + ) FINAL_VALIDATE_SCHEMA = _final_validate -def _configure_sdio(config: ConfigType) -> None: +def _configure_sdio_2x(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -202,7 +263,42 @@ def _configure_sdio(config: ConfigType) -> None: ) -def _configure_spi(config: ConfigType) -> None: +def _configure_sdio_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}", + True, + ) + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_{config[CONF_BUS_WIDTH]}", + True, + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK", config[CONF_CLK_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD", config[CONF_CMD_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN] + ) + if config[CONF_BUS_WIDTH] == 4: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3", config[CONF_D3_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ", + int(config[CONF_SDIO_FREQUENCY] // 1000), + ) + + +def _configure_spi_2x(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] @@ -250,11 +346,53 @@ def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config: ConfigType) -> None: - add_define("USE_ESP32_HOSTED") - transport = config[CONF_TYPE] - transport_prefix = "SDIO" if transport == "sdio" else "SPI" +def _configure_spi_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CLK_MHZ", int(config[CONF_FREQUENCY] // 1e6) + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MOSI_GPIO", config[CONF_MOSI_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_MISO_GPIO", config[CONF_MISO_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CLK_GPIO", config[CONF_CLK_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_SPI_CS_GPIO", config[CONF_CS_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_HANDSHAKE_GPIO", config[CONF_HANDSHAKE_PIN] + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_DATA_READY_GPIO", config[CONF_DATA_READY_PIN] + ) + # Handshake and data_ready polarity + if config[CONF_HANDSHAKE_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_HIGH", True + ) + else: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_LOW", True + ) + if config[CONF_DATA_READY_ACTIVE_HIGH]: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_HIGH", True + ) + else: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_LOW", True + ) + +def _configure_2x(config: ConfigType) -> None: + transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI" # Reset polarity if config[CONF_ACTIVE_HIGH]: esp32.add_idf_sdkconfig_option( @@ -274,24 +412,11 @@ async def to_code(config: ConfigType) -> None: f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT True, ) - - # Transport-specific configuration - if transport == "sdio": - _configure_sdio(config) + if config[CONF_TYPE] == "sdio": + _configure_sdio_2x(config) else: - _configure_spi(config) - - # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see - # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not - # gated on the `espnow` component being present: the shim is tiny and the - # esp_now_* symbols/CustomRpc calls it defines require these Kconfig options - # to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so - # coupling the two keeps the build consistent. When `espnow` is absent the - # symbols are simply unused and never register a callback at runtime. + _configure_spi_2x(config) if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: - add_define("USE_ESP_NOW_HOSTED") - # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides - esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True @@ -299,18 +424,64 @@ async def to_code(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS ) - # Place the transport mempool in PSRAM. Required on memory-tight host # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM # mempool allocation fails at boot with `sdio_mempool_create` assert. if config[CONF_USE_PSRAM]: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + +def _configure_3x(config: ConfigType) -> None: + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN] + ) + # Co-processor variant + esp32.add_idf_sdkconfig_option( + f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}", + True, + ) + if config[CONF_TYPE] == "sdio": + _configure_sdio_3x(config) + else: + _configure_spi_3x(config) + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS", + _MAX_CUSTOM_MSG_HANDLERS, + ) + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True) + + +async def to_code(config: ConfigType) -> None: + add_define("USE_ESP32_HOSTED") + use_3x = uses_esp_hosted_3x() + + if use_3x: + _configure_3x(config) + else: + _configure_2x(config) + + # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see + # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not + # gated on the `espnow` component being present: the shim is tiny and the + # esp_now_* symbols/CustomRpc calls it defines require the peer-data Kconfig + # options (set above) to link whenever esp_now_hosted.cpp compiles (which is + # on any P4 host), so coupling the two keeps the build consistent. When + # `espnow` is absent the symbols are simply unused and never register a + # callback at runtime. + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + add_define("USE_ESP_NOW_HOSTED") + # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") + # 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}" - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5") 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.13") diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index d9b375dd20c..c35301a6852 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -26,6 +26,8 @@ static const char *const TAG = "esp32_hosted.update"; // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// OTA begin blocks while the coprocessor erases its partition +constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // Interval/timeout IDs (uint32_t to avoid string comparison) @@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); // Begin OTA on coprocessor - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); container->end(); @@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->status_set_error(LOG_STR("Failed to begin OTA")); @@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) { this->update_info_.has_progress = false; this->publish_state(); - watchdog::WatchdogManager watchdog(60000); - #ifdef USE_ESP32_HOSTED_HTTP_UPDATE if (!this->stream_firmware_to_coprocessor_()) #else diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 65d56495af4..1a972896c3b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -26,7 +26,7 @@ dependencies: espressif/mdns: version: 1.12.0 espressif/esp_wifi_remote: - version: 1.6.3 + version: 1.6.5 rules: - if: "target in [esp32h2, esp32p4]" espressif/wifi_remote_over_eppp: From 30dc453dc134b47ab7246fcd94ed3367d3dbe316 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 23:30:51 +0200 Subject: [PATCH 681/759] [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) --- .../ethernet/ethernet_component_esp32.cpp | 46 +++++++++++++++++++ .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271ec..ea732e84ff9 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,9 @@ #include #include #include "esp_event.h" +#ifdef USE_PSRAM +#include +#endif // IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; // they are no longer included via esp_eth.h and need explicit includes. @@ -71,6 +74,32 @@ static const char *const TAG = "ethernet"; // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; +// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are +// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces. +#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP) +#define USE_ETHERNET_RX_PSRAM +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config() +static bool rx_psram_installed = false; + +// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP +// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is. +static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) { + auto *copy = static_cast(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (copy != nullptr) { + memcpy(copy, buffer, length); + free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc() + buffer = copy; + } else { + static bool warned = false; // once, this runs per frame in the driver's task + if (!warned) { + warned = true; + ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)"); + } + } + return esp_netif_receive(static_cast(priv), buffer, length, nullptr); +} +#endif + void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); this->mark_failed(); @@ -453,6 +482,16 @@ void EthernetComponent::ethernet_lazy_init_() { /* attach Ethernet driver to TCP/IP stack */ err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); ESPHL_ERROR_CHECK(err, "ETH netif attach error"); +#ifdef USE_ETHERNET_RX_PSRAM + // The glue frees every receive buffer with free(), so the replacement buffer must come from the heap + if (esp_psram_is_initialized()) { + err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_); + rx_psram_installed = err == ESP_OK; + if (!rx_psram_installed) { + ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -630,6 +669,13 @@ void EthernetComponent::dump_config() { this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +#ifdef USE_ETHERNET_RX_PSRAM + // Only known once the driver is up; with enable_on_boot: false that is after this dump + if (this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " RX frames: %s", + rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM")); + } +#endif } network::IPAddresses EthernetComponent::get_ip_addresses() { diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml new file mode 100644 index 00000000000..d454dbadc39 --- /dev/null +++ b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml @@ -0,0 +1,15 @@ +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi From 2d6a7dae547f0940f0c1deb76c6d201d435c5a20 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:40:39 +1000 Subject: [PATCH 682/759] [mipi_rgb] Fix SWRESET delay for st7701s (#17646) --- esphome/components/mipi/__init__.py | 10 ++- esphome/components/mipi_rgb/display.py | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 4 +- esphome/components/mipi_rgb/models/st7701s.py | 5 +- .../mipi_rgb/test_mipi_rgb_config.py | 51 ++++++++++++ .../mipi_rgb/test_reset_sequence.py | 81 +++++++++++++++++++ .../mipi_spi/test_get_sequence.py | 64 +++++++++++++++ 7 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/mipi_rgb/test_reset_sequence.py create mode 100644 tests/component_tests/mipi_spi/test_get_sequence.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3f73f963277..50b84b630d1 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -606,11 +606,11 @@ class DriverChip: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. - Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence + Append SLPOUT (if not suppressed by the model) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True If add_reset is True, a reset is prepended: a software reset when no reset pin is configured (and the model doesn't skip it), followed by a settling delay that - both a software and a hardware reset require. + both a software and a hardware reset require. The delay length is set via reset_delay, and defaults to 10ms. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -620,12 +620,16 @@ class DriverChip: sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] if add_reset: + # Matches the 1-255ms range map_sequence() already allows for a "delay N" entry. + reset_delay = self.get_default("reset_delay", 10) + if reset_delay < 1 or reset_delay > 255: + raise ValueError("reset_delay must be between 1 and 255ms") reset: list = [] # A software reset is only needed when there is no hardware reset pin. if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): reset.append((SWRESET,)) # Both a software and a hardware reset need a settling delay before further commands. - reset.append(delay(10)) + reset.append(delay(reset_delay)) sequence = reset + sequence # Set pixel format if not already in the custom sequence diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b91528160e2..9cba56a9763 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -285,7 +285,7 @@ async def to_code(config: ConfigType) -> None: if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) - sequence = model.get_sequence(config) + sequence = model.get_sequence(config, add_reset=True) cg.add(var.set_init_sequence(sequence)) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 3f83da7f803..034efb9c92a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -44,8 +44,10 @@ void MipiRgb::setup_enables_() { void MipiRgbSpi::setup() { this->setup_enables_(); this->spi_setup(); - this->write_init_sequence_(); this->common_setup_(); + if (this->is_failed()) + return; + this->write_init_sequence_(); } void MipiRgbSpi::write_command_(uint8_t value) { this->enable(); diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index cad5dc8e20d..b51e7447ad4 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -7,6 +7,10 @@ SDIR_CMD = 0xC7 class ST7701S(RgbDriverChip): + def __init__(self, *args, reset_delay=50, **kwargs): + kwargs["reset_delay"] = reset_delay + super().__init__(*args, **kwargs) + # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) @@ -49,7 +53,6 @@ st7701s = ST7701S( pclk_frequency="16MHz", pclk_inverted=True, initsequence=( - (0x01,), # Software Reset (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 (0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05), (0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,), diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index ac8e111ddba..e677577ec08 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -1,5 +1,7 @@ """Tests for mipi_rgb configuration validation.""" +from collections.abc import Generator + import pytest from esphome import config_validation as cv @@ -17,6 +19,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, VARIANT_ESP32S31, ) +from esphome.components.mipi import DriverChip import esphome.components.pca9554 # noqa: F401 import esphome.components.xl9535 # noqa: F401 from esphome.const import ( @@ -44,6 +47,19 @@ DATA_PINS = { } +@pytest.fixture(autouse=True) +def _remove_test_models() -> Generator[None]: + """Unregister chips created by a test. + + display.py modules drain DriverChip.models when first imported, so a + leftover TEST-* chip could become a selectable model there. + """ + existing = set(DriverChip.models) + yield + for name in set(DriverChip.models) - existing: + del DriverChip.models[name] + + def _set_s3(set_core_config: SetCoreConfigCallable) -> None: set_core_config( PlatformFramework.ESP32_IDF, @@ -176,6 +192,41 @@ def test_configuration_succeeds_on_supported_variants( CONFIG_SCHEMA(config) +def test_st7701s_default_reset_delay() -> None: + """ST7701S instances default to a 50ms reset delay. + + The datasheet's stated 5ms is too short in practice; ST7701S overrides the + DriverChip default of 10ms with its own default of 50ms. + """ + from esphome.components.mipi_rgb.models.st7701s import st7701s + + assert st7701s.get_default("reset_delay") == 50 + + +def test_st7701s_reset_delay_can_be_overridden() -> None: + """An explicit reset_delay overrides the ST7701S default of 50ms.""" + from esphome.components.mipi_rgb.models.st7701s import ST7701S + + chip = ST7701S("TEST-ST7701S-RESET-DELAY", width=480, height=480, reset_delay=99) + + assert chip.get_default("reset_delay") == 99 + + +def test_st7701s_extend_inherits_reset_delay_default() -> None: + """extend() carries the 50ms default forward to derived board models. + + Every shipped ST7701S variant is built via ``st7701s.extend(...)`` rather + than direct construction, so the override in ``ST7701S.__init__`` must + survive that path (see DriverChip.extend, which re-passes the copied + defaults as kwargs to the constructor). + """ + from esphome.components.mipi_rgb.models.st7701s import st7701s + + extended = st7701s.extend("TEST-ST7701S-EXTEND", width=480, height=480) + + assert extended.get_default("reset_delay") == 50 + + def test_only_on_variant_rejects_unsupported_variant( set_core_config: SetCoreConfigCallable, ) -> None: diff --git a/tests/component_tests/mipi_rgb/test_reset_sequence.py b/tests/component_tests/mipi_rgb/test_reset_sequence.py new file mode 100644 index 00000000000..50478361b14 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_reset_sequence.py @@ -0,0 +1,81 @@ +"""End-to-end tests for the mipi_rgb SPI reset sequence. + +These exercise the actual codegen path (mipi_rgb/display.py's +``model.get_sequence(config, add_reset=True)`` call) rather than calling +DriverChip.get_sequence directly, so a regression that drops add_reset or +reintroduces a hardcoded SWRESET into a model's initsequence would be caught +here. +""" + +from collections.abc import Callable +from pathlib import Path + +# A model with no reset_pin default: SWRESET ({1, 0}) is prepended ahead of the +# inherited ST7701S reset_delay ({50, 255}). +_NO_RESET_PIN_YAML = """ +esphome: + name: mipi-rgb-reset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +psram: + mode: octal +spi: + id: spi_bus + clk_pin: 10 + mosi_pin: 11 +display: + - platform: mipi_rgb + id: no_reset_display + spi_id: spi_bus + model: MAKERFABS-4 +""" + +# A model with a reset_pin default: no SWRESET, just the settling delay. +_RESET_PIN_YAML = """ +esphome: + name: mipi-rgb-reset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +psram: + mode: octal +spi: + id: spi_bus + clk_pin: 6 + mosi_pin: 7 +display: + - platform: mipi_rgb + id: has_reset_display + spi_id: spi_bus + model: WAVESHARE-3.16-320X820 +""" + + +def test_swreset_and_reset_delay_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset_pin gets SWRESET plus the ST7701S 50ms delay.""" + yaml_file = tmp_path / "no_reset.yaml" + yaml_file.write_text(_NO_RESET_PIN_YAML) + + main_cpp = generate_main(yaml_file) + + assert "no_reset_display->set_init_sequence({1, 0, 50, 255," in main_cpp + + +def test_reset_delay_only_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with a reset_pin default skips SWRESET but keeps the settling delay.""" + yaml_file = tmp_path / "has_reset.yaml" + yaml_file.write_text(_RESET_PIN_YAML) + + main_cpp = generate_main(yaml_file) + + assert "has_reset_display->set_init_sequence({50, 255," in main_cpp + assert "has_reset_display->set_init_sequence({1, 0," not in main_cpp diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py new file mode 100644 index 00000000000..263b567b9c9 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -0,0 +1,64 @@ +"""Tests for DriverChip.get_sequence's reset-delay handling.""" + +from collections.abc import Generator + +import pytest + +from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip + +# A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run +# without needing a full display configuration. +_BASE_CONFIG = {CONF_PIXEL_MODE: "16bit", CONF_INVERT_COLORS: False} + + +@pytest.fixture(autouse=True) +def _remove_test_models() -> Generator[None]: + """Unregister chips created by a test.""" + existing = set(DriverChip.models) + yield + for name in set(DriverChip.models) - existing: + del DriverChip.models[name] + + +def test_get_sequence_defaults_to_10ms_reset_delay() -> None: + """A model with no reset_delay default falls back to a 10ms settling delay.""" + chip = DriverChip("TEST-GET-SEQUENCE-DEFAULT") + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + # SWRESET ({1, 0}) is prepended (no reset pin configured), followed by the + # 10ms settling delay, flattened to {10, 255}. + assert sequence[:4] == (1, 0, 10, 255) + + +def test_get_sequence_uses_model_reset_delay_default() -> None: + """A model's own reset_delay default overrides the base 10ms default.""" + chip = DriverChip("TEST-GET-SEQUENCE-CUSTOM-DELAY", reset_delay=99) + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + assert sequence[:4] == (1, 0, 99, 255) + + +@pytest.mark.parametrize("reset_delay", [0, 256]) +def test_get_sequence_rejects_out_of_range_reset_delay(reset_delay: int) -> None: + """reset_delay outside 1-255ms is rejected. + + This matches the 1-255ms range map_sequence() already allows for a + "delay N" entry in a custom init sequence. + """ + chip = DriverChip("TEST-GET-SEQUENCE-BAD-DELAY", reset_delay=reset_delay) + + with pytest.raises(ValueError, match="reset_delay must be between 1 and 255ms"): + chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=True) + + +def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None: + """An out-of-range reset_delay is only checked when add_reset is requested. + + mipi_dsi calls get_sequence with add_reset=False and never uses + reset_delay, so an invalid default there should not raise. + """ + chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999) + + chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False) From 3f065530238d4553fb8511a5fef5509c0cfee662 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 01:57:25 +0200 Subject: [PATCH 683/759] [ltr501] Use the sub_* helpers for optional child entities (#19867) --- esphome/components/ltr501/sensor.py | 30 +++++++---------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index e27a7c323ef..51378e4cddf 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -229,29 +229,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS): - sens = await sensor.new_sensor(infrared_cnt_config) - cg.add(var.set_infrared_counts_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_full_spectrum_counts_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) - - if prox_cnt_config := config.get(CONF_PS_COUNTS): - sens = await sensor.new_sensor(prox_cnt_config) - cg.add(var.set_proximity_counts_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) + await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) From 08387ad289dfbff5d239b2ced914d64f90be9189 Mon Sep 17 00:00:00 2001 From: elwin loomis Date: Sat, 5 Sep 2026 16:08:21 -0500 Subject: [PATCH 684/759] [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_dsi/mipi_dsi.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0850b50c85d..0150cc25442 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -35,8 +35,8 @@ void MipiDsi::setup() { .bus_id = 0, // index from 0, specify the DSI host to use .num_data_lanes = this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability - .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY - .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps + // phy_clk_src left at 0 to enable runtime auto-select. + .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps }; auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); if (err != ESP_OK) { From 7e993016442bee0186d40af00ad2bdfd2fb42de0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 17:19:04 -0500 Subject: [PATCH 685/759] [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) --- esphome/components/esphome/ota/__init__.py | 12 +++++++-- tests/component_tests/ota/test_esphome_ota.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260c..bcf2a2271cb 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc1..235ad902dbc 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" From 3e5797d1531c161a1b945e4b5f35f2f06f3f6fb1 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Thu, 17 Sep 2026 04:47:40 -0500 Subject: [PATCH 686/759] [mixer] Don't discard a start request while reaping a stopped task (#19368) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index ef21da65c5a..85fb445d408 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -385,7 +385,8 @@ void MixerSpeaker::loop() { // Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } From 964a54f7a76ffa4432588a67f73746f4bcb83886 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:11 -0500 Subject: [PATCH 687/759] [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) --- esphome/components/esp32/crash_handler.cpp | 14 +++++++++----- esphome/components/esp32/crash_handler.h | 7 +------ esphome/components/esp32/hal.cpp | 8 -------- esphome/components/rp2/crash_handler.cpp | 22 +++++++++++++++++----- esphome/components/rp2/crash_handler.h | 3 ++- esphome/core/application.h | 4 ++-- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa0..b72a2777c7a 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ece..314be80314c 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f3..199cb89f516 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea216371..9bcdc8bee4e 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b0..3aec80b63b2 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac88..2836587dfd2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); From eaee1cdd8636c60e8cd69eac88bbf5866c645cbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:40:22 -0500 Subject: [PATCH 688/759] [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) --- esphome/espidf/runner.py | 99 +++++++++++++++++++---- tests/unit_tests/test_espidf_runner.py | 104 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7ed11d75543..bf563a4f438 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -74,6 +74,64 @@ FILTER_IDF_LINES: list[str] = [ r"Stopping at filesystem boundary", ] +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + + +def _get_kernel32(): + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class _Utf8Console: + """Keep an attached Windows console on UTF-8 for the length of the build. + + The build tree runs in UTF-8 mode, so esp_idf_size draws its table with + Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``, + and CMake re-decodes the child's output with the console code page before + printing it, which turns the table into mojibake on any code page but + UTF-8. Every process in the build shares this console, so switching it + here covers CMake too. The old code pages go back on exit so the user's + terminal is left as it was. + + A console that is already on UTF-8 is left alone. The code page belongs to + the console, not to this process, so a build that overlaps another one + must not save UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record the old pages first so a switch that fails part way through + # still gets put back on exit. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + def main() -> int: # ---- sys.path fix-up --------------------------------------------------- @@ -269,8 +327,29 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + class _FilteredStreams: + """Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims. + + On exit each shim releases a last line that never got its + terminator. The shims made here are drained rather than whatever + ``sys.stdout`` holds by then, which the script is free to replace. + """ + + def __init__(self, filter_lines: list[str] | None) -> None: + self._stdout = _FilteringTTYStream(sys.stdout, filter_lines) + self._stderr = _FilteringTTYStream(sys.stderr, filter_lines) + + def __enter__(self) -> None: + sys.stdout = self._stdout # type: ignore[assignment] + sys.stderr = self._stderr # type: ignore[assignment] + + def __exit__(self, *exc_info: object) -> None: + # Drain stderr from a finally so a surprise from the first one + # cannot strand the second. + try: + self._stdout.drain() + finally: + self._stderr.drain() # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -288,19 +367,11 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the streams get a chance to - # release a last line that never got its terminator. Drain the shims we - # made rather than sys.stdout, which the script is free to replace, and - # report instead of raising so cleanup cannot bury the real exit code. - try: + # fall through and exit with 0. Either way the context managers drain + # the streams and put the console back on the way out, and they report + # instead of raising so cleanup cannot bury the real exit code. + with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()): runpy.run_path(script_path, run_name="__main__") - finally: - # Drain stderr from a finally so a surprise from the first one cannot - # strand the second. - try: - stdout_shim.drain() - finally: - stderr_shim.drain() return 0 diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index e4cc6e137e7..71ccfee1f2c 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes( # Join before leaving the block, so the reader is done rather than # racing ``Popen`` closing the pipe under it. reader.join(1.0) + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_main_runs_the_build_with_a_utf8_console( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An attached console is switched to UTF-8 and then put back.""" + kernel32 = _FakeKernel32(850, 850) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [ + ("SetConsoleCP", runner.UTF8_CODEPAGE), + ("SetConsoleOutputCP", runner.UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_main_restores_the_console_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failing build must not leave the user's console on UTF-8.""" + kernel32 = _FakeKernel32(437, 437) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + _prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py") + + with pytest.raises(SystemExit): + runner.main() + + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_main_restores_the_console_when_the_switch_fails_part_way( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failed output page switch must not strand the changed input page.""" + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +def test_main_leaves_the_console_alone_when_there_is_none( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Without a console the code page calls return 0 and nothing is set.""" + kernel32 = _FakeKernel32(0, 0) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +def test_main_leaves_a_console_already_on_utf8_alone( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An overlapping build must not save UTF-8 as the page to restore.""" + kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert runner._get_kernel32() is None From c732903390d940c2928021955f08284daaf7f042 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:57:59 -0500 Subject: [PATCH 689/759] [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp32_hosted/__init__.py | 2 ++ .../esp32_hosted/test-no-wifi.esp32-p4-idf.yaml | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3f5a34bc735..4597904cc39 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -254,7 +254,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back - "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 21626e432b6..6943efc3cb8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None: # symbols are simply unused and never register a callback at runtime. if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: add_define("USE_ESP_NOW_HOSTED") + # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml new file mode 100644 index 00000000000..2f57abd296e --- /dev/null +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim. +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From 0575eb14f2fe6a99b0921e77c522d34619ac345a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 10:20:41 -0500 Subject: [PATCH 690/759] [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) --- esphome/components/api/api_server.cpp | 26 +++++- esphome/components/api/api_server.h | 5 ++ esphome/components/esphome/ota/__init__.py | 18 ++--- .../components/esphome/ota/ota_esphome.cpp | 22 +++-- esphome/components/esphome/ota/ota_esphome.h | 9 ++- esphome/components/noise/__init__.py | 18 +++-- esphome/core/defines.h | 1 - tests/component_tests/ota/test_esphome_ota.py | 26 +++--- .../host_ota_encrypted_safe_mode.yaml | 13 +++ ...ost_ota_provisioned_api_key_safe_mode.yaml | 11 +++ tests/integration/host_prefs.py | 35 ++++++-- tests/integration/test_host_ota.py | 80 +++++++++++++++++-- tests/integration/test_safe_mode_loop_runs.py | 12 +-- 13 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml create mode 100644 tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e9..d74de98c75e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -29,6 +29,29 @@ static const char *const TAG = "api"; // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_API_NOISE +static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL; +#endif + +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +bool load_saved_noise_psk(noise::psk_t &out) { + SavedNoisePsk saved; +#ifdef USE_PREFERENCE_KEY_LOOKUP + const bool loaded = + global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast(&saved), sizeof(saved)); +#else + // Slot backends need the reservation walk; it only lands on the record when the reservations before + // it match a normal boot, otherwise the type checked checksum fails the load + const bool loaded = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true).load(&saved); +#endif + // The all-zeros record means no key + if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk)) + return false; + out = saved.psk; + return true; +} +#endif + APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { @@ -43,8 +66,7 @@ void APIServer::setup() { #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build - uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); + this->noise_pref_ = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true); #ifndef USE_API_NOISE_PSK_FROM_YAML // A cleared record loads fine but holds no key if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11a..4aa0a422dd5 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -43,6 +43,11 @@ struct SavedNoisePsk { noise::psk_t psk; } PACKED; // NOLINT #endif +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when +/// there is no key +bool load_saved_noise_psk(noise::psk_t &out); +#endif class APIServer final : public Component, public Controller diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bcf2a2271cb..bace13c17fc 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -316,20 +316,16 @@ async def to_code(config: ConfigType) -> None: # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement api_conf = CORE.config.get(CONF_API) or {} - encryption_conf = config.get(CONF_ENCRYPTION) - own_key = None - if encryption_conf is not None and static_encryption_key(api_conf) is None: - own_key = encryption_conf[CONF_KEY] - if own_key is not None: + if key := static_encryption_key(config) or static_encryption_key(api_conf): + # Build time key: the ota keeps its own pointer so safe mode, which + # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) elif CONF_ENCRYPTION in api_conf: + # Runtime key: found in the api server, or in preferences in safe mode cg.add_define("USE_OTA_ENCRYPTION") - cg.add_define("USE_OTA_ENCRYPTION_FROM_API") - if static_encryption_key(api_conf) is None: - # The key arrives at runtime, so the offer has to look for it - cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") - if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if CONF_ENCRYPTION in config: cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3010df10561..d353d01d205 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,5 +1,5 @@ #include "ota_esphome.h" -#ifdef USE_OTA_ENCRYPTION_FROM_API +#ifdef USE_OTA_ENCRYPTION_PROVISIONED #include "esphome/components/api/api_server.h" #endif #ifdef USE_OTA @@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota"; #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { -#ifdef USE_OTA_ENCRYPTION_FROM_API - return api::global_api_server->get_noise_ctx(); -#else - return this->noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The api server holds the live key; safe mode never constructs it, and then + // noise_ctx_ holds the saved key setup() found, if any + if (api::global_api_server != nullptr) + return api::global_api_server->get_noise_ctx(); #endif + return this->noise_ctx_; } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; @@ -58,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() { } void ESPHomeOTAComponent::setup() { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Safe mode never constructs the api server, so read the key it saved + noise::psk_t psk; + if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) { + this->saved_psk_ = RAMAllocator().make_unique(psk); + if (this->saved_psk_ != nullptr) { + this->noise_ctx_.set_psk(this->saved_psk_->data()); + } + } +#endif this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { this->server_failed_(LOG_STR("creation")); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 68dd0ffb9ef..92ba094c8de 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) +#ifdef USE_OTA_ENCRYPTION /// psk points at 32 bytes that live in flash for the life of the program void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif @@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; }; - // The api server's live context when the api has encryption, else our own + // The api server's live context when it exists, otherwise our own (a build + // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); @@ -148,8 +149,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION -#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Backs noise_ctx_ in safe mode, where no api server holds the saved key + RAMUniquePtr saved_psk_; #endif RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 6067fde1642..47cd4cfc677 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,11 +5,12 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" noise_ns = cg.esphome_ns.namespace("noise") @@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None: def new_psk_progmem(parent_id: ID, key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer - so the key never occupies RAM.""" - return cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decode_encryption_key(key)), - ) + so the key never occupies RAM. Components sharing one key (api and ota) + share the array.""" + decoded = decode_encryption_key(key) + arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) + if (array := arrays.get(decoded)) is None: + array = arrays[decoded] = cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decoded), + ) + return array def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b78516c6ef8..94f7648f291 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -245,7 +245,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 235ad902dbc..d8bcd7e2758 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -476,43 +476,36 @@ def test_static_encryption_key() -> None: ("yaml_name", "defines_present", "defines_absent"), [ # An api key alone compiles the transport in without requiring it; - # the device uses the api server's key, not a copy + # the ota keeps its own pointer to the key so safe mode, which never + # constructs the api server, can still use it ( "api_key_offer", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A password still guards plaintext uploads on an offering device ( "api_key_offer_password", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"}, + {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # Without api encryption the ota key is the device's own ( "own_key", {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, - {"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"}, + {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A key provisioned at runtime lives in the api server; the device # offers with it once provisioned and never requires it ( "runtime_api_key", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_FROM_API", - "USE_OTA_ENCRYPTION_PROVISIONED", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"}, {"USE_OTA_ENCRYPTION_REQUIRED"}, ), # No api encryption at all keeps the noise glue out of the build @@ -522,7 +515,6 @@ def test_static_encryption_key() -> None: { "USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED", }, ), @@ -541,8 +533,10 @@ def test_encryption_offer_codegen( assert defines_present <= defines assert not (defines_absent & defines) encrypted = "USE_OTA_ENCRYPTION" in defines_present - own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present + own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + # The api shares the ota's array instead of emitting the same key twice + assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml new file mode 100644 index 00000000000..57f3d57a010 --- /dev/null +++ b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml @@ -0,0 +1,13 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + encryption: +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml new file mode 100644 index 00000000000..c00954515b1 --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml @@ -0,0 +1,11 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a010..75c3ff81b01 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -14,6 +14,12 @@ from __future__ import annotations from pathlib import Path import struct +_ENTRY = struct.Struct(" Path: """Return the on-disk prefs file path for a host-platform device.""" @@ -34,16 +40,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: for key, data in entries.items(): if len(data) > 255: raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: - """Write a single preference entry, replacing the file's contents. +def read_host_prefs(device_name: str) -> dict[int, bytes]: + """Read the preference entries of a host-platform device; empty when + the file does not exist.""" + path = host_prefs_path(device_name) + if not path.exists(): + return {} + payload = path.read_bytes() + entries: dict[int, bytes] = {} + pos = 0 + while pos < len(payload): + key, length = _ENTRY.unpack_from(payload, pos) + pos += _ENTRY.size + entries[key] = payload[pos : pos + length] + pos += length + return entries - Returns the path that was written. - """ - return write_host_prefs(device_name, {key: data}) + +def force_safe_mode(device_name: str) -> None: + """Make the next boot of a host-platform device enter safe mode; other + saved preferences are kept.""" + entries = read_host_prefs(device_name) + entries[_SAFE_MODE_RTC_KEY] = struct.pack(" None: + """Provision PROVISIONING_PSK over the api and wait for it to activate.""" + async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -227,6 +237,31 @@ async def test_host_ota_encrypted( await dev.ota(None, API_KEY, "encrypted OTA reported failure") +@pytest.mark.asyncio +async def test_host_ota_encrypted_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Safe mode never constructs the api server, so an encrypted OTA with the + api key has to run on the ota component's own copy of that key.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # The api port never opens in safe mode, so wait for the log line instead + force_safe_mode(DEVICE_NAME) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + # The safe mode boot clears the counter, so the re-exec boots normally + await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure") + + @pytest.mark.asyncio async def test_host_ota_api_key_offer_with_password( yaml_config: str, @@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key( None, None, "plaintext upload to an unprovisioned device must succeed" ) - async with api_client_connected( - port=dev.api_port, noise_psk=ZERO_PSK - ) as client: - assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True - await asyncio.sleep(KEY_ACTIVATION_DELAY) + await _provision_key(dev, api_client_connected) key = PROVISIONING_PSK.decode() await dev.ota( @@ -319,6 +350,45 @@ async def test_host_ota_provisioned_api_key( await dev.ota(None, None, "plaintext must stay accepted on an offering device") +@pytest.mark.asyncio +async def test_host_ota_provisioned_api_key_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """Safe mode never constructs the api server, so the OTA has to load the + provisioned key from preferences itself to keep encrypting there.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _provision_key(dev, api_client_connected) + + # The saved key is already on disk; a host reboot outside an OTA just + # exits, so safe mode takes a second start + force_safe_mode(DEVICE_NAME) + key = PROVISIONING_PSK.decode() + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + await dev.ota( + None, + key, + "encrypted upload with the provisioned key must succeed in safe mode", + ) + # The re-exec boots normally and the api reads the same record + async with api_client_connected(port=dev.api_port, noise_psk=key): + pass + + @pytest.mark.asyncio async def test_host_ota_rejects_garbage( yaml_config: str, diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 652877fc092..55756fca13d 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -25,19 +25,13 @@ from __future__ import annotations import asyncio import re -import struct import pytest from .conftest import run_binary -from .host_prefs import clear_host_prefs, write_host_pref +from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter -# Must match esphome::safe_mode::RTC_KEY in safe_mode.h -SAFE_MODE_RTC_KEY = 233825507 -# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC -ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE - DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs( # Compile finished successfully; pre-populate prefs so the *next* run # enters safe mode immediately. - write_host_pref( - DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" Date: Sat, 19 Sep 2026 18:08:56 -0400 Subject: [PATCH 691/759] [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418) Co-authored-by: Claude Fable 5.1 --- esphome/components/esp32/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4597904cc39..e9a041b0b1b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2628,6 +2628,11 @@ async def to_code(config): config.get(CONF_ENGINEERING_SAMPLE, False), ) + # ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not + # understand %zu or %lld and crashes on any %s that follows one. + if variant == VARIANT_ESP32C2: + add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False) + # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, # and for PSRAM users saves significant IRAM by keeping C library functions in ROM. From 92059abf6cc40bbfa1b7865b118c4a60b91092f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 23:56:28 +0100 Subject: [PATCH 692/759] [web_server] Mask the value of a password text entity, not only its state (#19385) --- esphome/components/web_server/web_server.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e54..58fecd04d8e 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1412,8 +1412,11 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st json::JsonBuilder builder; JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), + start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); From 4af2db7e8a597b4df118df44b4736a8e56ec1f51 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 25 Sep 2026 05:00:33 +0100 Subject: [PATCH 693/759] [nrf52] set WDT timeout to 30sec for Adafruit bootloader (#19596) Co-authored-by: J. Nick Koston --- esphome/components/nrf52/dfu.cpp | 1 + esphome/components/zephyr/hal.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 24dee997269..0122ed9b31b 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c3..10e8340a409 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -27,7 +27,11 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else From b6d70d4e349561b787d1b3e664c7c16d2952637d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:04:17 +1000 Subject: [PATCH 694/759] [mipi_spi] Fix reversion in dc/cs sequence for spi_16 (#19745) --- esphome/components/mipi_spi/mipi_spi.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7c..ee1264cf234 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,20 +246,18 @@ 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) { - // 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->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +269,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); From 43b6b41d16fb0ddf818ae2c6cfd51e34de76f739 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:12:34 +0300 Subject: [PATCH 695/759] [lvgl] Honor esphome build_flags when generating lv_conf.h (#19743) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 15 +++-- .../lvgl/test_lv_conf_build_flags.py | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_build_flags.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2d2f1d6288b..382e483d0cb 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -169,11 +170,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' diff --git a/tests/component_tests/lvgl/test_lv_conf_build_flags.py b/tests/component_tests/lvgl/test_lv_conf_build_flags.py new file mode 100644 index 00000000000..eb576fa8fac --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_build_flags.py @@ -0,0 +1,59 @@ +"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option +the configuration does not use, so ``lv_conf.h`` must leave alone the options +the user defines in build flags. Otherwise the header, which is included after +the compiler ``-D`` flags, turns the option off again. + +Build flags can come from ``esphome: build_flags:`` or from the deprecated +``esphome: platformio_options: build_flags:``; both must be honoured. +""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lvgl import defines as df, generate_lv_conf_h +from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS +from esphome.core import CORE + + +def _set_esphome_config( + build_flags: list[str] | None = None, + pio_build_flags: list[str] | str | None = None, +) -> None: + pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags} + CORE.config = { + CONF_ESPHOME: { + CONF_PLATFORMIO_OPTIONS: pio_options, + CONF_BUILD_FLAGS: build_flags or [], + } + } + + +def test_unused_define_is_disabled_without_build_flag() -> None: + _set_esphome_config() + assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines() + + +@pytest.mark.parametrize( + "flags", + [ + {"build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"build_flags": ["-D LV_USE_OBSERVER"]}, + {"pio_build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"pio_build_flags": "-DLV_USE_OBSERVER=1"}, + ], + ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"], +) +def test_build_flag_define_is_not_disabled(flags: dict) -> None: + _set_esphome_config(**flags) + assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines() + + +def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None: + _set_esphome_config(build_flags=["-DLV_USE_ARC=1"]) + df.add_define("LV_USE_ARC") + with caplog.at_level(logging.WARNING): + generate_lv_conf_h() + assert "LV_USE_ARC" in caplog.text From eca4c2d0a5de6f1c432f7b0e343339b2b96e4913 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 23:30:51 +0200 Subject: [PATCH 696/759] [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) --- .../ethernet/ethernet_component_esp32.cpp | 46 +++++++++++++++++++ .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271ec..ea732e84ff9 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,9 @@ #include #include #include "esp_event.h" +#ifdef USE_PSRAM +#include +#endif // IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; // they are no longer included via esp_eth.h and need explicit includes. @@ -71,6 +74,32 @@ static const char *const TAG = "ethernet"; // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; +// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are +// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces. +#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP) +#define USE_ETHERNET_RX_PSRAM +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config() +static bool rx_psram_installed = false; + +// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP +// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is. +static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) { + auto *copy = static_cast(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (copy != nullptr) { + memcpy(copy, buffer, length); + free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc() + buffer = copy; + } else { + static bool warned = false; // once, this runs per frame in the driver's task + if (!warned) { + warned = true; + ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)"); + } + } + return esp_netif_receive(static_cast(priv), buffer, length, nullptr); +} +#endif + void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); this->mark_failed(); @@ -453,6 +482,16 @@ void EthernetComponent::ethernet_lazy_init_() { /* attach Ethernet driver to TCP/IP stack */ err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); ESPHL_ERROR_CHECK(err, "ETH netif attach error"); +#ifdef USE_ETHERNET_RX_PSRAM + // The glue frees every receive buffer with free(), so the replacement buffer must come from the heap + if (esp_psram_is_initialized()) { + err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_); + rx_psram_installed = err == ESP_OK; + if (!rx_psram_installed) { + ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -630,6 +669,13 @@ void EthernetComponent::dump_config() { this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +#ifdef USE_ETHERNET_RX_PSRAM + // Only known once the driver is up; with enable_on_boot: false that is after this dump + if (this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " RX frames: %s", + rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM")); + } +#endif } network::IPAddresses EthernetComponent::get_ip_addresses() { diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml new file mode 100644 index 00000000000..d454dbadc39 --- /dev/null +++ b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml @@ -0,0 +1,15 @@ +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi From 7a4ffa98f67c6633e9cc119bb423ceb6e842fa88 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:57:36 +1300 Subject: [PATCH 697/759] Bump version to 2026.9.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a5d6da63332..65d975b61ea 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0 +PROJECT_NUMBER = 2026.9.1 # 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 e6165bf16b0..0ec6a95869e 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0" +__version__ = "2026.9.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From aba15f8ff6df8baf60506398369db83d6c1c56c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 01:57:37 +0200 Subject: [PATCH 698/759] [thermopro_ble] Use the sub_* helpers for optional child entities (#19868) --- esphome/components/thermopro_ble/sensor.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index c660e5fcef8..ed42312a26b 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -82,18 +82,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) From 8032542076c6fda21112f53365a95ffb48f5a786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 01:57:51 +0200 Subject: [PATCH 699/759] [as5600] Use the sub_* helpers for optional child entities (#19869) --- esphome/components/as5600/sensor/__init__.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 847b89f1214..4cf1e95f695 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -87,18 +87,8 @@ async def to_code(config: ConfigType) -> None: if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE): cg.add(var.set_out_of_range_mode(out_of_range_mode_config)) - if raw_position_config := config.get(CONF_RAW_POSITION): - sens = await sensor.new_sensor(raw_position_config) - cg.add(var.set_raw_position_sensor(sens)) - - if gain_config := config.get(CONF_GAIN): - sens = await sensor.new_sensor(gain_config) - cg.add(var.set_gain_sensor(sens)) - - if magnitude_config := config.get(CONF_MAGNITUDE): - sens = await sensor.new_sensor(magnitude_config) - cg.add(var.set_magnitude_sensor(sens)) - - if status_config := config.get(CONF_STATUS): - sens = await sensor.new_sensor(status_config) - cg.add(var.set_status_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_POSITION, var.set_raw_position_sensor) + await sensors(CONF_GAIN, var.set_gain_sensor) + await sensors(CONF_MAGNITUDE, var.set_magnitude_sensor) + await sensors(CONF_STATUS, var.set_status_sensor) From 571cbb72ebb07b2f982c3c2a8302029bd05deb19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 01:58:05 +0200 Subject: [PATCH 700/759] [xiaomi_lywsd03mmc] Use the sub_* helpers for optional child entities (#19870) --- esphome/components/xiaomi_lywsd03mmc/sensor.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index f4e24a97d0b..32977d0ed6f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -68,12 +68,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) From bf7aa1dc605e3aa769c51822806b31b9bc60a533 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 01:58:53 +0200 Subject: [PATCH 701/759] [sm300d2] Use the sub_* helpers for optional child entities (#19866) --- esphome/components/sm300d2/sensor.py | 29 ++++++++-------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index e0f2b7b280e..4245283ab48 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -99,24 +99,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde_sensor(sens)) - if CONF_TVOC in config: - sens = await sensor.new_sensor(config[CONF_TVOC]) - cg.add(var.set_tvoc_sensor(sens)) - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_FORMALDEHYDE, var.set_formaldehyde_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) From 21c96049189ed325de677b9916bdc881b8efc42b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 08:56:17 +0200 Subject: [PATCH 702/759] [network] Keep lwip off the PSRAM tier when Ethernet is configured (#19367) --- esphome/components/network/__init__.py | 35 ++++++++++-- esphome/components/wifi/__init__.py | 2 +- tests/component_tests/network/__init__.py | 11 ++++ .../config/high_perf_ethernet_no_psram.yaml | 20 +++++++ .../config/high_perf_ethernet_psram.yaml | 23 ++++++++ .../config/high_perf_wifi_ethernet_psram.yaml | 30 +++++++++++ .../config/high_perf_wifi_no_psram.yaml | 14 +++++ .../network/config/high_perf_wifi_psram.yaml | 18 +++++++ .../network/test_high_performance.py | 54 +++++++++++++++++++ .../component_tests/network/test_priority.py | 9 +--- .../network/test_tcp_send_buffer.py | 18 ++----- .../network/test-priority.esp32-idf.yaml | 6 +++ 12 files changed, 215 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml create mode 100644 tests/component_tests/network/config/high_perf_ethernet_psram.yaml create mode 100644 tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml create mode 100644 tests/component_tests/network/config/high_perf_wifi_no_psram.yaml create mode 100644 tests/component_tests/network/config/high_perf_wifi_psram.yaml create mode 100644 tests/component_tests/network/test_high_performance.py diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 9a47679ba3c..decee27bcf8 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -9,9 +9,11 @@ from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( CONF_ENABLE_IPV6, + CONF_ETHERNET, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT, CONF_PRIORITY, + CONF_WIFI, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv @@ -28,6 +30,16 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" +# TCP receive window of the optimized lwip tier (PSRAM not guaranteed) +TCP_WND_OPTIMIZED = 65534 +# Ethernet drivers keep every queued frame in internal RAM, so ethernet-only builds +# get a window sized for a LAN round trip and the stock lwip input mailbox. Dual +# wifi + ethernet builds keep wifi's sizes; the ethernet component moves their +# received frames to PSRAM instead. +TCP_WND_ETHERNET = 16384 +TCPIP_RECVMBOX_ETHERNET = 32 +TCPIP_RECVMBOX_OPTIMIZED = 64 + # lwIP queues at most this many unsent/unacked bytes per TCP socket; the # stock ESP-IDF default (5744 bytes) stalls bursty senders like a Bluetooth # proxy streaming GATT notifications. Bounds follow the lwIP guidance for the @@ -166,6 +178,8 @@ def require_high_performance_networking() -> None: Configuration is PSRAM-aware: - With PSRAM guaranteed: Aggressive settings (512 RX buffers, 512KB TCP windows) - Without PSRAM: Conservative optimized settings (64 buffers, 65KB TCP windows) + - Ethernet only: 16KB TCP windows regardless of PSRAM, because ESP-IDF + ethernet drivers keep received frames in internal RAM Example: from esphome.components import network @@ -417,8 +431,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32 and should_enable: # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() + # ESP-IDF ethernet drivers malloc() received frames into internal RAM, so + # lwip is never sized for PSRAM on an ethernet-only build. + ethernet_only = CONF_ETHERNET in CORE.config and CONF_WIFI not in CORE.config - if psram_guaranteed: + if psram_guaranteed and not ethernet_only: _LOGGER.info( "Applying high-performance lwip settings (PSRAM guaranteed): 512KB TCP windows, 512 mailbox sizes" ) @@ -451,15 +468,25 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_OVERSIZE_MSS", True) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_QUEUE_OOSEQ", True) else: + # Every queued byte is internal RAM on ethernet and a LAN round trip needs + # little window; wifi keeps the larger sizes. + if ethernet_only: + tcp_window, tcpip_mailbox = TCP_WND_ETHERNET, TCPIP_RECVMBOX_ETHERNET + else: + tcp_window, tcpip_mailbox = TCP_WND_OPTIMIZED, TCPIP_RECVMBOX_OPTIMIZED _LOGGER.info( - "Applying optimized lwip settings: 65KB TCP windows, 64 mailbox sizes" + "Applying optimized lwip settings: %dKB TCP windows, %d entry input mailbox", + tcp_window // 1000, + tcpip_mailbox, ) # PSRAM not guaranteed - use more conservative, but still optimized settings # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 + # The send buffer stays at 65534 on ethernet too: it only fills under outbound + # load, and bursty senders such as a Bluetooth proxy stall on the IDF default. add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) - add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 65534) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) - add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c9616a6c290..1ba89468413 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -771,7 +771,7 @@ async def to_code(config): "Applying high-performance WiFi settings (PSRAM guaranteed): 512 RX buffers, 32 TX buffers" ) # PSRAM is guaranteed - use aggressive settings - # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE is set to true in networking component + # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE may be set to true in networking component # Based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 # Large dynamic RX buffers (requires PSRAM) diff --git a/tests/component_tests/network/__init__.py b/tests/component_tests/network/__init__.py index e69de29bb2d..b4ed442ff8d 100644 --- a/tests/component_tests/network/__init__.py +++ b/tests/component_tests/network/__init__.py @@ -0,0 +1,11 @@ +"""Shared helpers for the network component tests.""" + +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.core import CORE + + +def sdkconfig_option(name: str) -> int | bool | None: + """Return a generated sdkconfig value, or None when unset or not ESP32.""" + if KEY_ESP32 not in CORE.data: + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name) diff --git a/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml b/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml new file mode 100644 index 00000000000..31858c3fc5e --- /dev/null +++ b/tests/component_tests/network/config/high_perf_ethernet_no_psram.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: LAN8720 + mdc_pin: 23 + mdio_pin: 32 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 0 + power_pin: 33 + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_ethernet_psram.yaml b/tests/component_tests/network/config/high_perf_ethernet_psram.yaml new file mode 100644 index 00000000000..3407fd5ffe8 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_ethernet_psram.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO14 + reset_pin: GPIO9 + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml new file mode 100644 index 00000000000..b9b49c825db --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_ethernet_psram.yaml @@ -0,0 +1,30 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO14 + reset_pin: GPIO9 + +network: + priority: + - wifi + - ethernet + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml new file mode 100644 index 00000000000..c24e4b62539 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_no_psram.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/config/high_perf_wifi_psram.yaml b/tests/component_tests/network/config/high_perf_wifi_psram.yaml new file mode 100644 index 00000000000..efdad282a44 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_psram.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + enable_high_performance: true diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py new file mode 100644 index 00000000000..c291806ac4b --- /dev/null +++ b/tests/component_tests/network/test_high_performance.py @@ -0,0 +1,54 @@ +"""Tests for the ``network: enable_high_performance:`` lwip tier selection. + +Ethernet drivers keep received frames in internal RAM, so an ethernet-only +build must keep lwip off the PSRAM tier even when PSRAM is guaranteed. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.network import require_high_performance_networking +from tests.component_tests.network import sdkconfig_option + + +@pytest.mark.parametrize( + ( + "fixture", + "window", + "tcp_mailbox", + "tcpip_mailbox", + "window_scale", + "wifi_rx_buffers", + ), + [ + ("high_perf_wifi_psram.yaml", 512000, 512, 512, True, 512), + ("high_perf_wifi_no_psram.yaml", 65534, 64, 64, None, 64), + # dual builds keep wifi's sizes; the ethernet component moves frames to PSRAM + ("high_perf_wifi_ethernet_psram.yaml", 512000, 512, 512, True, 512), + ("high_perf_ethernet_no_psram.yaml", 16384, 64, 32, None, None), + # ethernet only with PSRAM guaranteed: the cell the guard exists for + ("high_perf_ethernet_psram.yaml", 16384, 64, 32, None, None), + ], +) +def test_lwip_tier( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + window: int, + tcp_mailbox: int, + tcpip_mailbox: int, + window_scale: bool | None, + wifi_rx_buffers: int | None, +) -> None: + # The wifi component only reacts to a component request, so request it the + # way sendspin does; the config key alone decides the lwip tier. + require_high_performance_networking() + generate_main(component_config_path(fixture)) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") == window + assert sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE") == tcp_mailbox + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == tcpip_mailbox + assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale + # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index 041b358ddaf..57e412ea198 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -19,6 +19,7 @@ from esphome.components.network import ( from esphome.const import CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv +from tests.component_tests.network import sdkconfig_option from tests.component_tests.types import SetCoreConfigCallable @@ -217,13 +218,7 @@ def test_no_primary_interface_define_without_priority( def _dns_per_default_netif_option() -> bool | None: - from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS - - if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all - return None - return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( - "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" - ) + return sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF") @pytest.mark.parametrize( diff --git a/tests/component_tests/network/test_tcp_send_buffer.py b/tests/component_tests/network/test_tcp_send_buffer.py index 64989212439..bf68c7c11cd 100644 --- a/tests/component_tests/network/test_tcp_send_buffer.py +++ b/tests/component_tests/network/test_tcp_send_buffer.py @@ -14,32 +14,24 @@ import pytest from voluptuous import Invalid from esphome import config_validation as cv -from esphome.components.esp32.const import ( - KEY_SDKCONFIG_OPTIONS, - KEY_VARIANT, - VARIANT_ESP32, -) +from esphome.components.esp32.const import KEY_VARIANT, VARIANT_ESP32 from esphome.components.network import ( CONF_TCP_SEND_BUFFER, CONFIG_SCHEMA, TCP_SEND_BUFFER_MAX, TCP_SEND_BUFFER_MIN, ) -from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework -from esphome.core import CORE +from esphome.const import KEY_FRAMEWORK_VERSION, PlatformFramework +from tests.component_tests.network import sdkconfig_option from tests.component_tests.types import SetCoreConfigCallable -def _sdkconfig_option(name: str) -> int | None: - return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name) - - def test_tcp_send_buffer_sets_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: generate_main(component_config_path("tcp_send_buffer.yaml")) - assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000 + assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000 def test_tcp_send_buffer_overrides_high_performance( @@ -48,7 +40,7 @@ def test_tcp_send_buffer_overrides_high_performance( ) -> None: """An explicit size wins over the high performance bundle's 65534.""" generate_main(component_config_path("tcp_send_buffer_high_perf.yaml")) - assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384 + assert sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384 @pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX]) diff --git a/tests/components/network/test-priority.esp32-idf.yaml b/tests/components/network/test-priority.esp32-idf.yaml index baa821a2348..ccafd74543f 100644 --- a/tests/components/network/test-priority.esp32-idf.yaml +++ b/tests/components/network/test-priority.esp32-idf.yaml @@ -3,6 +3,11 @@ # it must actually compile (not just validate) to guard the reconciler wiring. # WiFi is listed first so the build also exercises the wifi-primary branch in # network/util.cpp (the ethernet-primary branch matches the legacy order). +# PSRAM guaranteed plus high performance networking: a dual build keeps wifi's +# PSRAM tier, so this compiles the sdkconfig a wifi + ethernet audio device gets. +psram: + ignore_not_found: false + wifi: ssid: MySSID password: password1 @@ -21,3 +26,4 @@ network: priority: - wifi - ethernet + enable_high_performance: true From 54a923842505b41209010341db4d2eb9a91d9bbf Mon Sep 17 00:00:00 2001 From: FuNK3Y Date: Tue, 29 Sep 2026 11:38:01 +0200 Subject: [PATCH 703/759] [epaper_spi] reTerminal Sticky: Four-level grayscale for SSD1677 panels, and a full-refresh action (#19213) Co-authored-by: Pierre Co-authored-by: Claude Opus 5 Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/epaper_spi/colorconv.h | 41 +- esphome/components/epaper_spi/display.py | 38 +- esphome/components/epaper_spi/epaper_spi.cpp | 13 +- esphome/components/epaper_spi/epaper_spi.h | 16 +- .../components/epaper_spi/epaper_spi_mono.h | 4 +- .../epaper_spi/epaper_spi_ssd1677.cpp | 101 +++++ .../epaper_spi/epaper_spi_ssd1677.h | 49 +++ .../epaper_spi/epaper_spi_ssd1677_gray4.cpp | 129 ++++++ .../epaper_spi/epaper_spi_ssd1677_gray4.h | 49 +++ .../components/epaper_spi/models/__init__.py | 35 ++ .../components/epaper_spi/models/jd79660.py | 4 +- .../components/epaper_spi/models/ssd1677.py | 67 +++- .../components/epaper_spi/models/ssd1683.py | 3 +- .../components/epaper_spi/models/uc8179.py | 3 +- .../components/epaper_spi/models/waveshare.py | 8 +- .../config/full_update_next_test.yaml | 29 ++ .../config/ssd1677_border_waveform_test.yaml | 22 + .../epaper_spi/config/ssd1677_gray4_test.yaml | 19 + tests/component_tests/epaper_spi/test_init.py | 70 ++++ .../epaper_spi/test_model_requirements.py | 376 ++++++++++++++++++ tests/components/epaper_spi/common.h | 50 +++ .../display/test_ssd1677_gray4_transfer.cpp | 298 ++++++++++++++ .../display/test_ssd1677_transfer.cpp | 233 +++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 20 + 24 files changed, 1635 insertions(+), 42 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677.h create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h create mode 100644 tests/component_tests/epaper_spi/config/full_update_next_test.yaml create mode 100644 tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml create mode 100644 tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml create mode 100644 tests/component_tests/epaper_spi/test_model_requirements.py create mode 100644 tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp create mode 100644 tests/components/epaper_spi/display/test_ssd1677_transfer.cpp diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 7b7c48c0b0f..0240aa5cccf 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -16,6 +16,28 @@ namespace esphome::epaper_spi { /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; +/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); +} + +/** Map RGB color to a single monochrome bit + * + * @param color RGB color to convert from + * @return 1 = white, 0 = black + */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) + * + * @param color RGB color to convert from + * @return Gray level: 0 = black, 3 = white + */ +constexpr uint8_t color_to_gray4(Color color) { + const uint8_t level = (uint8_t) ((rec601_luma(color) + 32u) >> 6); // quantize 0..255 to 0..3, rounded + return level > 3 ? 3 : level; +} + /** Map RGB color to discrete BWYR hex 4 color key * * @tparam NATIVE_COLOR Type of native hardware color values @@ -25,7 +47,6 @@ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; * @param hw_yellow Native value for yellow * @param hw_red Native value for red * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow, @@ -38,11 +59,7 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } // --- Step 2: Check for Primary/Secondary Colors --- @@ -96,7 +113,6 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C * @param hw_green Native value for green * @param hw_blue Native value for blue * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -105,10 +121,7 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); @@ -158,7 +171,6 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV * @param hw_blue Native value for blue * @param hw_orange Native value for orange * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -167,10 +179,7 @@ constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATI const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index e9da924de57..3fa504539be 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -1,7 +1,9 @@ +from collections.abc import Callable import importlib import pkgutil +from typing import Any -from esphome import core, pins +from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation @@ -54,6 +56,12 @@ EPaperBase = epaper_spi_ns.class_( ) Transform = epaper_spi_ns.enum("Transform") +automation.register_apply_action( + "epaper_spi.full_update_next", + automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}), + automation.ApplyCall("request_full_update()"), +) + # Import all models dynamically from the models package for module_info in pkgutil.iter_modules(models.__path__): importlib.import_module(f".models.{module_info.name}", package=__package__) @@ -70,6 +78,23 @@ DIMENSION_SCHEMA = cv.Schema( TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} +def _full_update_every_validator( + model: models.EpaperModel, +) -> Callable[[Any], int]: + if model.get_default("partial_update"): + return cv.int_range(1, 255) + + def validate(value: Any) -> int: + value = cv.int_range(1, 255)(value) + if value != 1: + raise cv.Invalid( + f"{model.name} does not support partial update; full_update_every must be 1" + ) + return value + + return validate + + def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) @@ -96,7 +121,9 @@ def model_schema(config): cv.Required(CONF_MIRROR_Y): cv.boolean, } ), - cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255), + cv.Optional( + CONF_FULL_UPDATE_EVERY, default=1 + ): _full_update_every_validator(model), model.option(CONF_BUSY_PIN): pins.gpio_input_pin_schema, model.option(CONF_CS_PIN): pins.gpio_output_pin_schema, model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema, @@ -132,8 +159,15 @@ def customise_schema(config): extra=cv.ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() config = model_schema(config)(config) + config = model.validate_config(config) width, height = model.get_dimensions(config) + if width % (width_multiple := model.get_default("width_multiple", 1)): + raise cv.Invalid( + f"{model.name} requires a width that is a multiple of {width_multiple}", + path=[CONF_DIMENSIONS], + ) display.add_metadata( config[CONF_ID], width, diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3b3418d911f..1fab038c283 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -196,6 +196,15 @@ void EPaperBase::process_state_() { break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda + if (this->full_update_requested_) { + // Refresh the whole panel even if nothing was drawn + this->full_update_requested_ = false; + this->update_count_ = 0; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { this->set_state_(EPaperState::IDLE); return; @@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { return; const size_t byte_position = y * this->row_width_ + x / 8; const uint8_t bit_position = x % 8; - const uint8_t pixel_bit = 0x80 >> bit_position; + const uint8_t pixel_bit = 0x80u >> bit_position; const auto original = this->buffer_[byte_position]; - if ((color_to_bit(color) == 0)) { + if (color_to_mono(color) == 0) { this->buffer_[byte_position] = original & ~pixel_bit; } else { this->buffer_[byte_position] = original | pixel_bit; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 8e2fd78e621..0040c867b20 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,5 +1,6 @@ #pragma once +#include "colorconv.h" #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" @@ -80,15 +81,7 @@ class EPaperBase : public Display, DisplayType get_display_type() override { return this->display_type_; }; - // Default implementations for monochrome displays - static uint8_t color_to_bit(Color color) { - // It's always a shade of gray. Map to BLACK or WHITE. - // We split the luminance at a suitable point - if ((color.r + color.g + color.b) >= 382) { - return 1; - } - return 0; - } + // Default implementation for monochrome displays void fill(Color color) override { // If clipping is active, fall back to base implementation if (this->get_clipping().is_set()) { @@ -96,7 +89,7 @@ class EPaperBase : public Display, return; } - auto pixel_color = color_to_bit(color) ? 0xFF : 0x00; + auto pixel_color = color_to_mono(color) ? 0xFF : 0x00; // We store 8 pixels per byte this->buffer_.fill(pixel_color); @@ -114,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + // Make the next update a full one. Applied when that update starts, so one in progress is not affected. + void request_full_update() { this->full_update_requested_ = true; } protected: int get_height_internal() override { return this->height_; }; @@ -185,6 +180,7 @@ class EPaperBase : public Display, uint8_t transform_{}; uint8_t effective_transform_{}; uint8_t update_count_{}; + bool full_update_requested_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index f44b59e803e..d0740595fc5 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,8 +9,8 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up } diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp new file mode 100644 index 00000000000..e845a5d5858 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -0,0 +1,101 @@ +#include "epaper_spi_ssd1677.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.ssd1677"; + +void EPaperSSD1677::setup() { + EPaperMono::setup(); + if (!this->is_failed()) + this->init_comparison_frame_(); +} + +void EPaperSSD1677::init_comparison_frame_() { + if (!this->is_using_partial_update_()) + return; + if (!this->sent_.init(this->plane_row_length_() * this->height_)) { + ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas"); + } +} + +void EPaperSSD1677::plane_row(size_t y, uint8_t *out) { + const size_t row_length = this->plane_row_length_(); + const size_t data_idx = y * row_length; + for (size_t i = 0; i != row_length; i++) + out[i] = this->buffer_[data_idx + i]; +} + +// Nothing a partial update needs is kept in controller RAM any more, so skip the reset for those. +bool EPaperSSD1677::reset() { + if (this->update_count_ != 0 && this->sent_.is_valid()) + return true; + return EPaperMono::reset(); +} + +// The window always covers the whole panel, so each plane is the frame's bytes in order. Where +// those bytes are already stored as the plane needs them (the comparison frame, and a 1-bit +// buffer) they are written straight from the buffer, as many at a time as the time slice allows; +// otherwise they are built a row at a time by plane_row(). +bool HOT EPaperSSD1677::transfer_data() { + if (!this->sent_.is_valid()) + return EPaperMono::transfer_data(); + + const auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->plane_ == 0) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + this->set_window(); + this->command(this->plane_ == 0 ? 0x26 : 0x24); + } + // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after + // boot): send the new frame to both planes. + const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; + const bool direct = send_copy || this->buffer_is_plane(); + const auto &source = send_copy ? this->sent_ : this->buffer_; + const size_t row_length = this->plane_row_length_(); + const size_t plane_length = row_length * this->height_; + // Roughly what the bus moves in one time slice, so a slice is not overrun by much + const size_t max_chunk = std::max(this->data_rate_ / 8000 * MAX_TRANSFER_TIME, MAX_TRANSFER_SIZE); + SmallBufferWithHeapFallback<128> row_alloc(direct ? 0 : row_length); + this->start_data_(); + while (this->current_data_index_ != plane_length) { + size_t length; + const uint8_t *data; + if (direct) { + data = source.get_span(this->current_data_index_, length); + length = std::min(length, max_chunk); + } else { + // Always at the start of a row here, since this path sends whole rows only + this->plane_row(this->current_data_index_ / row_length, row_alloc.get()); + data = row_alloc.get(); + length = row_length; + } + this->write_array(data, length); + if (this->plane_ == 1) + this->sent_.write(this->current_data_index_, data, length); + this->current_data_index_ += length; + if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + this->disable(); + this->current_data_index_ = 0; + if (this->plane_ == 0) { + this->plane_ = 1; + return false; + } + this->plane_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h new file mode 100644 index 00000000000..ddf3d4e0892 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -0,0 +1,49 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome SSD1677 with partial refreshes that leave unchanged pixels alone. + * + * A partial refresh drives each pixel from the pair (RAM 0x26 = the image on the panel, + * RAM 0x24 = the new image) across the whole panel; the RAM window only scopes a write. + * EPaperMono writes 0x26 once and afterwards only the changed window of 0x24, which relies on + * the controller's RAM being unchanged from one update to the next. On this controller it is not: + * the hardware reset at the start of each update loses it, and even without resets, keeping 0x26 + * in step one window at a time left unchanged areas alternating between older frames. Either way + * unchanged pixels get driven on every partial and wash out. + * + * So before every refresh this class writes both planes over the whole panel: 0x26 from a copy of + * the frame last sent, 0x24 from the buffer. GxEPD2 likewise rewrites both planes after each + * partial on this controller. The copy is taken as the data goes out, not from the buffer, which + * may already hold the next frame by the time the refresh completes. + */ +class EPaperSSD1677 : public EPaperMono { + public: + EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + + void setup() override; + + protected: + // Allocates the comparison frame when partial updates are enabled. Separate from setup() so it + // can run without a bus. + void init_comparison_frame_(); + // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. + size_t plane_row_length_() const { return (this->width_ + 7) / 8; } + // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. + // A subclass with a deeper buffer returns false and overrides plane_row(). + virtual bool buffer_is_plane() const { return true; } + // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. + virtual void plane_row(size_t y, uint8_t *out); + bool reset() override; + bool transfer_data() override; + + split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows + uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24 +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp new file mode 100644 index 00000000000..18fbb016f3b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp @@ -0,0 +1,129 @@ +#include "epaper_spi_ssd1677_gray4.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4"; + +// Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into +// one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit +static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) { + uint8_t out = 0; + for (const uint8_t src : {first, second}) { + for (uint8_t shift = 6;; shift -= 2) { + const uint8_t level = (src >> shift) & 0x03; + out = (uint8_t) ((out << 1) | (high_bit ? (level >> 1) : (level & 1))); + if (shift == 0) + break; + } + } + return out; +} + +void EPaperSSD1677Gray4::fill(Color color) { + if (this->get_clipping().is_set()) { + // Falls back to the generic per-pixel implementation for the clipped rectangle. + EPaperBase::fill(color); + return; + } + const uint8_t level = color_to_gray4(color); + this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6))); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + const uint8_t level = color_to_gray4(color); + const size_t byte_position = (size_t) y * this->row_width_ + x / 4; + const uint8_t shift = (uint8_t) (6 - 2 * (x % 4)); // most significant pixel first + const uint8_t original = this->buffer_[byte_position]; + this->buffer_[byte_position] = (uint8_t) ((original & ~(0x03 << shift)) | (level << shift)); +} + +// A partial update reduces each pixel to its high bit: levels 2 and 3 are light, 0 and 1 dark. +void EPaperSSD1677Gray4::plane_row(size_t y, uint8_t *out) { + const size_t src_row = y * this->row_width_; + for (size_t i = 0; i != this->plane_row_length_(); i++) + out[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], true); +} + +// the high bit of every pixel's level goes to the new (bw) plane +// (0x24), the low bit to the old (red) plane (0x26) +bool HOT EPaperSSD1677Gray4::transfer_data() { + if (this->is_partial_push_()) + return EPaperSSD1677::transfer_data(); + + auto start_time = millis(); + const bool first_pass = this->send_red_; + if (this->current_data_index_ == 0) { + if (first_pass) { + // With partial updates enabled the window follows the changed area, but the four-level + // refresh drives every pixel from both planes, and the reset before it does not keep RAM. + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + this->set_window(); + } + this->command(first_pass ? 0x24 : 0x26); + this->current_data_index_ = this->y_low_; + } + const size_t plane_row_length = (this->x_high_ - this->x_low_) / 8; + // Stack-backed for every panel width in practice; only a custom `dimensions:` far wider than any + // supported panel would fall back to the heap. + SmallBufferWithHeapFallback<128> bytes_to_send_alloc(plane_row_length); + uint8_t *bytes_to_send = bytes_to_send_alloc.get(); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", plane_row_length, this->current_data_index_, + (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4; + for (size_t i = 0; i != plane_row_length; i++) { + const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass); + // The OTP grayscale waveform treats data as inverted relative to monochrome + bytes_to_send[i] = (uint8_t) ~plane; + // What the next partial update compares against: the high bits, as a black-and-white frame. + if (first_pass && this->sent_.is_valid()) + this->sent_[this->current_data_index_ * plane_row_length + i] = plane; + } + ++this->current_data_index_; + this->write_array(bytes_to_send, plane_row_length); + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (first_pass) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +void EPaperSSD1677Gray4::refresh_screen(bool partial) { + if (this->is_partial_push_()) { + ESP_LOGV(TAG, "Black-and-white partial refresh"); + // The border follows the LUT selected in 0x3C. The model's setting (sent with the init + // sequence) picks the LUT that is white under the four-level waveform's inverted data; under + // the black-and-white waveform that LUT drives black and the border darkens, so use LUT1. + this->cmd_data(0x3C, {0x01}); + EPaperSSD1677::refresh_screen(true); + return; + } + ESP_LOGV(TAG, "Four-level refresh"); + this->cmd_data(0x1A, {0x67, 0x00}); // force temperature by OTP + this->cmd_data(0x22, {0xD7}); // four-level update sequence, panel's OTP waveform + this->command(0x20); // master activation +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h new file mode 100644 index 00000000000..47436d2db1d --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -0,0 +1,49 @@ +#pragma once + +#include "colorconv.h" +#include "epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi { + +/** + * Four-level grayscale for SSD1677 panels. + * + * The SSD1677 has two independent 1-bit RAM planes, normally used for a + * black/white and a red plane. This class writes image data to both, + * splitting each pixel's 2-bit gray level across them, and triggers the + * panel's own OTP grayscale waveform instead of the normal monochrome update + * sequence. No custom LUT upload needed for any currently supported panel, + * the OTP waveform is used instead. + * + * The framebuffer therefore packs 2 bits per pixel (4 per byte, most + * significant pixel first) instead of EPaperMono's 1 bit. + * + * The grayscale waveform has no partial form: master activation redraws the whole panel + * regardless of the RAM window. So a full update is a four-level refresh, and when partial updates + * are enabled (full_update_every > 1, which the model only allows on explicit request) a partial + * update is EPaperSSD1677's black-and-white one, each pixel reduced to light or dark. The partial + * waveform also drives unchanged pixels towards black or white, so from the first partial update + * the whole panel loses its gray levels until the next full update. + */ +class EPaperSSD1677Gray4 : public EPaperSSD1677 { + public: + EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { + this->row_width_ = (width + 3) / 4; // 4 pixels per byte + this->buffer_length_ = (size_t) this->row_width_ * height; + } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + // A partial update if partial updates are enabled and this is not a full one. + bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } + bool buffer_is_plane() const override { return false; } + void plane_row(size_t y, uint8_t *out) override; + void refresh_screen(bool partial) override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 34e65061f31..fbedd6371d1 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,7 +2,9 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.core import CORE from esphome.cpp_generator import MockObj +from esphome.types import ConfigType class EpaperModel: @@ -48,6 +50,16 @@ class EpaperModel: """ return {} + def validate_config(self, config: ConfigType) -> ConfigType: + """ + Validate the configuration as a whole, once the schema has been applied. + The base implementation accepts it unchanged; specific models override this for + rules that span several options. + :param config: The validated configuration + :return: The configuration, possibly updated + """ + return config + async def to_code(self, var: MockObj, config: dict) -> dict: """ Generate model-specific code for the options added by add_options(). @@ -89,3 +101,26 @@ class EpaperModel: defaults = self.defaults.copy() defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) + + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a0457c5812a..ca824dccd4d 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -10,7 +10,9 @@ from . import EpaperModel class JD79660(EpaperModel): def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs): - super().__init__(name, class_name, **kwargs) + # Only a fast_update sequence lets the driver do anything but a full refresh + kwargs.setdefault("partial_update", fast_update is not None) + super().__init__(name, class_name=class_name, **kwargs) self.fast_update = fast_update def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required: diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index 13f10350457..2249529611e 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -1,12 +1,56 @@ -from esphome.const import CONF_DATA_RATE +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_FULL_UPDATE_EVERY +from esphome.types import ConfigType from . import EpaperModel +CONF_BORDER_WAVEFORM = "border_waveform" +CONF_MONOCHROME_PARTIAL_UPDATES = "monochrome_partial_updates" + +# partial_update value for models whose partial updates are black and white only +MONOCHROME = "monochrome" + class SSD1677(EpaperModel): - def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults): + def __init__( + self, + name: str, + class_name: str = "EPaperSSD1677", + data_rate: str = "20MHz", + border_waveform: int = 0x01, + **defaults: Any, + ) -> None: defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults[CONF_BORDER_WAVEFORM] = border_waveform + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) + + def get_config_options(self) -> dict: + options = { + self.option(CONF_BORDER_WAVEFORM): cv.hex_uint8_t, + } + if self.get_default("partial_update") == MONOCHROME: + options[cv.Optional(CONF_MONOCHROME_PARTIAL_UPDATES, default=False)] = ( + cv.boolean + ) + return options + + def validate_config(self, config: ConfigType) -> ConfigType: + if ( + self.get_default("partial_update") == MONOCHROME + and config[CONF_FULL_UPDATE_EVERY] > 1 + and not config[CONF_MONOCHROME_PARTIAL_UPDATES] + ): + raise cv.Invalid( + f"{self.name} can only update partially in black and white, and a partial " + "update reduces the whole panel to black and white until the next full " + f"update. Set '{CONF_MONOCHROME_PARTIAL_UPDATES}: true' to accept this, " + "or leave full_update_every at 1", + path=[CONF_FULL_UPDATE_EVERY], + ) + return config # fmt: off def get_init_sequence(self, config: dict): @@ -15,13 +59,14 @@ class SSD1677(EpaperModel): (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) - (0x3C, 0x01), # Set border waveform + (0x3C, config[CONF_BORDER_WAVEFORM]), # Set border waveform (0x11, 3), # Set transform ) ssd1677 = SSD1677("ssd1677") + wave_4_26 = ssd1677.extend( "waveshare-4.26in", width=800, @@ -52,7 +97,8 @@ ssd1677.extend( mirror_x=True, ) -ssd1677.extend( +# Sticky - monochrome version +seeed_sticky = ssd1677.extend( "seeed-reterminal-sticky", width=800, height=480, @@ -63,4 +109,15 @@ ssd1677.extend( reset_pin=17, busy_pin=18, data_rate="10MHz", + requires={"psram"}, +) + +# Sticky - 4 level grayscale; partial updates only in black and white, on request +seeed_sticky.extend( + "seeed-reterminal-sticky-gray4", + class_name="EPaperSSD1677Gray4", + border_waveform=0x00, + partial_update=MONOCHROME, + # each plane byte is built from two whole buffer bytes + width_multiple=8, ) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index 983f5bb382e..b43168a336e 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -6,7 +6,8 @@ from . import EpaperModel class SSD1683(EpaperModel): def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index bea133c3282..91c649808a8 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -32,7 +32,8 @@ class UC8179(EpaperModel): **defaults: Any, ) -> None: defaults.setdefault(CONF_DATA_RATE, data_rate) - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) def get_init_sequence(self, config: dict) -> tuple: """Generate the initialization sequence for UC8179 mono displays. diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index 74a288977d5..aecda723643 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -6,8 +6,12 @@ from . import EpaperModel class WaveshareModel(EpaperModel): - def __init__(self, name, lut, lut_partial=None, **defaults): - super().__init__(name, "EpaperWaveshare", **defaults) + def __init__( + self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults + ): + # A partial LUT is what lets EpaperWaveshare do partial refresh + defaults.setdefault("partial_update", lut_partial is not None) + super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial diff --git a/tests/component_tests/epaper_spi/config/full_update_next_test.yaml b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml new file mode 100644 index 00000000000..de5e678a700 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml @@ -0,0 +1,29 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + full_update_every: 5 + dimensions: + width: 200 + height: 200 + +binary_sensor: + - platform: gpio + pin: GPIO27 + name: Trigger + on_press: + - epaper_spi.full_update_next: epaper_display diff --git a/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml new file mode 100644 index 00000000000..3d15ab8f905 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + dimensions: + width: 200 + height: 200 + border_waveform: 0x1A diff --git a/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml new file mode 100644 index 00000000000..d1078072986 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +psram: + mode: octal + speed: 80MHz + +spi: + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + id: epaper_display + model: seeed-reterminal-sticky-gray4 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 5e2e7d6013b..9d6ebe831a0 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -312,6 +312,66 @@ def test_model_with_full_update_every( ) +def test_update_interval_below_model_minimum_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """update_interval faster than the model's minimum_update_interval is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="at least"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "update_interval": "500ms", + } + ) + + +def test_reset_duration_over_max_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """reset_duration over the 500ms cap is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="at most"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "reset_duration": "600ms", + } + ) + + def test_busy_pin_input_mode_ssd1677( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], @@ -481,6 +541,16 @@ def test_enable_pin_code_generation( assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp +def test_full_update_next_action_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The epaper_spi.full_update_next action targets the configured display.""" + main_cpp = generate_main(component_config_path("full_update_next_test.yaml")) + + assert "epaper_display->request_full_update();" in main_cpp + + def test_model_with_no_default_init_sequence_generates( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/epaper_spi/test_model_requirements.py b/tests/component_tests/epaper_spi/test_model_requirements.py new file mode 100644 index 00000000000..26c50a133a0 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_model_requirements.py @@ -0,0 +1,376 @@ +"""Tests for the SSD1677 border_waveform option and EpaperModel.check_requirements().""" + +from collections.abc import Callable, Generator +from pathlib import Path +import re +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.epaper_spi.display import CONFIG_SCHEMA, MODELS +from esphome.components.epaper_spi.models import EpaperModel +from esphome.components.epaper_spi.models.ssd1677 import CONF_BORDER_WAVEFORM +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _ssd1677_config(**overrides: Any) -> ConfigType: + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 200}, + } + config.update(overrides) + return config + + +def _setup_esp32( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + variant: str = VARIANT_ESP32, + board: str = "esp32dev", +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: board, KEY_VARIANT: variant}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + +@pytest.fixture +def temp_model() -> Generator[Callable[..., EpaperModel]]: + """Register a throwaway EpaperModel for a test and remove it from the shared registry after.""" + created: list[EpaperModel] = [] + + def _make(name: str, **defaults: Any) -> EpaperModel: + model = EpaperModel(name, class_name="EPaperMono", **defaults) + created.append(model) + return model + + yield _make + for model in created: + MODELS.pop(model.name, None) + + +# --- border_waveform --------------------------------------------------------- + + +def test_border_waveform_default_mono( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """ssd1677 defaults border_waveform to 0x01.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config()) + + assert result[CONF_BORDER_WAVEFORM] == 0x01 + + +def test_border_waveform_default_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The 4-level grayscale variant defaults border_waveform to 0x00, independently of mono.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result[CONF_BORDER_WAVEFORM] == 0x00 + + +def test_border_waveform_override( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """An explicit border_waveform overrides the model default.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x1A)) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_accepts_hex_string( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform accepts a hex string like the YAML author would write.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform="0x1A")) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_out_of_range( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform rejects values that don't fit in a byte.""" + _setup_esp32(set_core_config, set_component_config) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x100)) + + +def test_border_waveform_in_generated_init_sequence( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The configured border_waveform byte reaches the generated init sequence. + + Command 0x3C (60) is followed by a length of 1 and the waveform byte. + """ + main_cpp = generate_main(component_config_path("ssd1677_border_waveform_test.yaml")) + + assert re.search(r"60,\s*1,\s*0x1A,", main_cpp) + + +# --- full_update_every / supports_partial_update ------------------------------ + + +def test_full_update_every_rejected_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The gray4 driver's partial updates are black and white and flatten the whole + panel, so full_update_every > 1 is refused unless that is explicitly accepted.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates: true"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + } + ) + + +def test_full_update_every_accepted_for_gray4_with_monochrome_partials( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With the trade-off accepted, the gray4 driver takes full_update_every > 1.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + "monochrome_partial_updates": True, + } + ) + + assert result["full_update_every"] == 5 + + +def test_monochrome_partial_updates_not_offered_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The option only exists where partial updates lose something.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "monochrome_partial_updates": True, + } + ) + + +def test_full_update_every_default_accepted_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Leaving full_update_every at its default of 1 is fine for the gray4 driver.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result["full_update_every"] == 1 + + +def test_full_update_every_accepted_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The mono sibling model supports partial update, unaffected by the gray4 restriction.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "full_update_every": 5, + } + ) + + assert result["full_update_every"] == 5 + + +# --- check_requirements ------------------------------------------------------- + + +def test_requirement_missing_raises( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """seeed-reterminal-sticky requires psram; without it, config validation fails.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires component 'psram'"): + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_satisfied_does_not_raise( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With psram present at the top level, seeed-reterminal-sticky validates.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + assert result["model"] == "SEEED-RETERMINAL-STICKY" + + +def test_requirement_check_skipped_without_raw_config( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With no raw_config (e.g. a schema invoked directly, as in these tests), the + requirement check is a no-op rather than a false failure.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + assert CORE.raw_config is None + + # Should not raise even though "psram" is required and nothing was configured. + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_missing_multiple_pluralised( + temp_model: Callable[..., EpaperModel], +) -> None: + """The error message pluralises "component(s)" and lists every missing one.""" + model = temp_model("test-multi-requirement", requires={"aaa", "bbb"}) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires components 'aaa', 'bbb'"): + model.check_requirements() + + +# --- width_multiple ----------------------------------------------------------- + + +@pytest.mark.parametrize("width", [804, 801]) +def test_gray4_width_not_multiple_of_8_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + width: int, +) -> None: + """The gray4 plane split reads two whole buffer bytes per plane byte, so width must be a multiple of 8.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": width, "height": 480}, + } + ) + + +def test_gray4_width_multiple_of_8_accepted( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": 808, "height": 480}, + } + ) + + assert result["dimensions"]["width"] == 808 + + +def test_mono_ssd1677_accepts_any_width( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The width restriction applies only to the gray4 model.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(dimensions={"width": 204, "height": 200})) + + assert result["dimensions"]["width"] == 204 + + +# --- extend(class_name=...) -------------------------------------------------- + + +def test_gray4_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """seeed-reterminal-sticky-gray4 generates the EPaperSSD1677Gray4 driver, not EPaperMono.""" + main_cpp = generate_main(component_config_path("ssd1677_gray4_test.yaml")) + + assert "epaper_spi::EPaperSSD1677Gray4" in main_cpp + assert "epaper_spi::EPaperMono" not in main_cpp diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index 5ac12afa6a6..f21e674fde2 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -2,6 +2,9 @@ #include +#include +#include + #include "esphome/components/spi/spi.h" #include "esphome/core/hal.h" @@ -47,4 +50,51 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// SPI delegate that records what reaches the bus, filing each payload under the command it +/// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on +/// each data row, so a transfer can be driven past its yield deadline. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc, uint32_t row_transfer_ms = 0) + : dc_(dc), row_transfer_ms_(row_transfer_ms) {} + + uint8_t transfer(uint8_t data) override { + this->record_(&data, 1); + return 0; + } + void write_array(const uint8_t *ptr, size_t length) override { + this->record_(ptr, length); + if (this->dc_->level && this->row_transfer_ms_ != 0) { + const uint32_t until = millis() + this->row_transfer_ms_; + while (millis() < until) { + } + } + } + + void clear() { + this->commands.clear(); + this->data.clear(); + } + + std::vector commands; + std::map> data; + + protected: + void record_(const uint8_t *ptr, size_t length) { + if (!this->dc_->level) { + for (size_t i = 0; i != length; i++) { + this->commands.push_back(ptr[i]); + this->last_command_ = ptr[i]; + } + return; + } + auto &payload = this->data[this->last_command_]; + payload.insert(payload.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; + uint32_t row_transfer_ms_; + uint8_t last_command_{0}; +}; + } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp new file mode 100644 index 00000000000..1a66efe9839 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp @@ -0,0 +1,298 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677Gray4 : public EPaperSSD1677Gray4 { + public: + TestableSSD1677Gray4(uint16_t width, uint16_t height) : EPaperSSD1677Gray4("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + /// As configured with monochrome_partial_updates: full_update_every > 1. + void install_with_partials(spi::SPIDelegate *delegate) { + this->install(delegate); + this->set_full_update_every(5); + this->init_comparison_frame_(); + ASSERT_TRUE(this->sent_.is_valid()); + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + using EPaperSSD1677Gray4::refresh_screen; + using EPaperSSD1677Gray4::transfer_data; + + RecordingPin dc; +}; + +using Bytes = std::vector; + +namespace { + +/// A gray that lands squarely on each of the four levels. +Color color_for_level(uint8_t level) { + static const uint8_t GRAYS[4] = {0, 64, 128, 255}; + const uint8_t v = GRAYS[level]; + return Color(v, v, v); +} + +void draw_row(TestableSSD1677Gray4 &display, int y, const std::vector &levels) { + for (size_t x = 0; x != levels.size(); x++) + display.draw_pixel_at((int) x, y, color_for_level(levels[x])); +} + +} // namespace + +/// Each pixel's 2-bit level is split across the RAM planes: the high bit to 0x24, the low bit to +/// 0x26, both inverted because the four-level waveform reads 1 as white. +TEST(EPaperSSD1677Gray4, SplitsEachLevelAcrossBothPlanes) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.run_push(); + + // levels 0 1 2 3 0 1 2 3 + // high bit 0 0 1 1 0 0 1 1 = 0x33, inverted 0xCC + // low bit 0 1 0 1 0 1 0 1 = 0x55, inverted 0xAA + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); +} + +/// Two buffer bytes (4 pixels each) make one plane byte (8 pixels), leftmost pixel in the most +/// significant bit. An asymmetric row catches a swapped pair or reversed bit order. +TEST(EPaperSSD1677Gray4, PacksPixelsLeftmostFirstAcrossSourceBytes) { + TestableSSD1677Gray4 display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}); + display.run_push(); + + // high bits: pixel 0 (3) and pixel 15 (2) -> 0x80 0x01, inverted 0x7F 0xFE + // low bits: pixel 0 (3) only -> 0x80 0x00, inverted 0x7F 0xFF + EXPECT_EQ(bus.data[0x24], (Bytes{0x7F, 0xFE})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x7F, 0xFF})); +} + +/// The high-bit plane goes out first, each plane exactly once per push. +TEST(EPaperSSD1677Gray4, WritesTheHighBitPlaneBeforeTheLowBitPlane) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.run_push(); + + const auto &cmds = bus.commands; + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x24), 1); + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x26), 1); + EXPECT_LT(std::find(cmds.begin(), cmds.end(), 0x24) - cmds.begin(), + std::find(cmds.begin(), cmds.end(), 0x26) - cmds.begin()); +} + +/// A push that yields partway through must resume the right plane at the right row. +TEST(EPaperSSD1677Gray4, ResumesBothPlanesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install(&bus); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 1, {1, 1, 1, 1, 1, 1, 1, 1}); + draw_row(display, 2, {2, 2, 2, 2, 2, 2, 2, 2}); + draw_row(display, 3, {3, 3, 3, 3, 3, 3, 3, 3}); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + // rows at levels 0..3: high bits 0 0 1 1, low bits 0 1 0 1, each inverted across the row + EXPECT_EQ(bus.data[0x24], (Bytes{0xFF, 0xFF, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0x00, 0xFF, 0x00})); +} + +/// Without partial updates enabled (the default) every refresh is the four-level sequence, even if +/// the update count says otherwise. +TEST(EPaperSSD1677Gray4, WithoutPartialUpdatesEveryRefreshIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + + EXPECT_EQ(bus.commands, (Bytes{0x1A, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x1A], (Bytes{0x67, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})); +} + +// --- With monochrome partial updates ------------------------------------------------------------ + +/// A full update is still four-level. It also records, as the frame the next partial update +/// compares against, what the panel shows in black-and-white terms: the high bit of each level. +TEST(EPaperSSD1677Gray4, FullPushRecordsTheHighBitsForTheNextPartial) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})) << "full update is no longer the four-level split"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); + bus.clear(); + + // Nothing changed: old and new planes must match, or the partial drives every pixel. + display.set_update_count(1); + display.run_push(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x33})) << "comparison frame is not the high bits"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33})); +} + +/// A partial update sends the comparison frame to 0x26 and the new frame's high bits to 0x24, +/// not inverted (it runs the black-and-white waveform), over the whole panel. +TEST(EPaperSSD1677Gray4, PartialPushSendsTheHighBitsInBlackAndWhite) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3}); + draw_row(display, 1, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 1, {3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_dirty(0, 1, 8, 2); // only the start of the second row changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0xFF, 0x00, 0x00})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33, 0xFF, 0xF0, 0x00})) << "new plane is not the whole frame's high bits"; +} + +/// The new plane of a partial update is built a row at a time; a push that yields partway through +/// must resume at the right row. +TEST(EPaperSSD1677Gray4, PartialPushResumesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + // The buffer starts white; darken all of row 0 and the right half of row 2 + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 2, {3, 3, 3, 3, 0, 0, 0, 0}); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xF0, 0xFF})); +} + +/// Regression test: a full update requested while a partial one is being sent must not switch the +/// push to the four-level transfer halfway, which misread the partial's progress and never finished. +TEST(EPaperSSD1677Gray4, FullUpdateRequestDuringAPartialPushWaitsForTheNextUpdate) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(1); + ASSERT_FALSE(display.transfer_data()); + display.request_full_update(); + int calls = 1; + while (!display.transfer_data()) + ASSERT_LT(++calls, 20) << "partial push never finished"; + + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xFF, 0xFF})); + bus.clear(); + display.refresh_screen(true); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "refresh does not match the partial data sent"; +} + +/// The four-level refresh follows a reset, which loses controller RAM, so it must send the whole +/// panel even when partial updates are enabled but the comparison frame could not be allocated. +TEST(EPaperSSD1677Gray4, FourLevelPushCoversTheWholePanelWithoutAComparisonFrame) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_full_update_every(5); // partial updates on, but no comparison frame + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "four-level update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "four-level update did not send the whole old plane"; +} + +/// A full update resets the controller, which does not keep RAM, so even when only part of the +/// frame changed it must send the whole panel. +TEST(EPaperSSD1677Gray4, FullPushWithPartialsEnabledCoversTheWholePanel) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "full update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "full update did not send the whole old plane"; +} + +/// The refresh matches what was sent: black-and-white for a partial update, four-level for a full. +TEST(EPaperSSD1677Gray4, PartialRefreshIsBlackAndWhiteAndFullIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + EXPECT_EQ(bus.commands, (Bytes{0x3C, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "partial update did not use the black-and-white waveform"; + // The model's border setting is right for the four-level waveform only; under this one it + // would drive the border black on every partial. + EXPECT_EQ(bus.data[0x3C], (Bytes{0x01})) << "partial update did not switch the border to LUT1"; + bus.clear(); + + display.set_update_count(0); + display.refresh_screen(false); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})) << "full update did not use the four-level waveform"; + EXPECT_EQ(bus.data.count(0x3C), 0u) << "full update overrode the model's border setting"; +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp new file mode 100644 index 00000000000..cad853b1973 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp @@ -0,0 +1,233 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677 : public EPaperSSD1677 { + public: + TestableSSD1677(uint16_t width, uint16_t height) : EPaperSSD1677("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + this->init_comparison_frame_(); + } + + bool has_comparison_frame() const { return this->sent_.is_valid(); } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + } + + /// Fill the frame with a byte pattern that differs per seed; returns it. + std::vector set_pattern(uint8_t seed) { + std::vector frame; + for (size_t i = 0; i != this->buffer_length_; i++) { + frame.push_back((uint8_t) (seed + i * 7)); + this->buffer_[i] = frame.back(); + } + return frame; + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// One call into the transfer; false while there is more to send. + bool step() { return this->transfer_data(); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + /// Run the UPDATE state, with nothing drawn, and report whether a push follows. + bool run_update_state() { + this->set_auto_clear(false); + this->set_dirty(this->width_, this->height_, 0, 0); + this->state_ = EPaperState::UPDATE; + this->process_state_(); + return this->state_ == EPaperState::RESET; + } + uint8_t update_count() const { return this->update_count_; } + + bool reset_in(EPaperState state) { + this->state_ = state; + return this->reset(); + } + + RecordingPin dc; + RecordingPin reset_pin; +}; + +using Bytes = std::vector; + +/// A full push ignores the old-image plane, and on the first push after boot the comparison frame +/// holds nothing real yet, so the new frame goes to both planes. +TEST(EPaperSSD1677, FullPushSendsTheNewFrameToBothPlanes) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +/// Regression test. +/// +/// A partial refresh drives every pixel from the pair (0x26 = the image on the panel, 0x24 = the +/// new image), across the whole panel whatever RAM window was written. The controller does not +/// keep its RAM intact between updates, so sending only the changed window of 0x24 - and 0x26 once +/// - leaves the pair wrong outside that window: unchanged pixels get driven on every partial and +/// wash out. Both planes must go out whole, 0x26 holding the frame actually on the panel. +TEST(EPaperSSD1677, PartialPushComparesAgainstTheFrameOnThePanel) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(8, 1, 16, 2); // only the last byte changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})) << "new plane is not the whole new frame"; + // The RAM window, set once per plane, must span the panel too, not the changed rectangle. + EXPECT_EQ(bus.data[0x44], (Bytes{0, 0, 15, 0, 0, 0, 15, 0})) << "x window is not the whole panel"; + EXPECT_EQ(bus.data[0x45], (Bytes{0, 0, 1, 0, 0, 0, 1, 0})) << "y window is not the whole panel"; +} + +/// The comparison frame must record the bytes that went to 0x24, not whatever the buffer holds +/// later: LVGL can draw into the buffer while a push is in progress. Here the buffer changes +/// between the two planes of a push; the next push must compare against what was actually sent. +TEST(EPaperSSD1677, ComparisonFrameIsWhatWasSentNotTheBuffer) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x11, 0x11, 0x11, 0x11}); + display.set_update_count(0); + display.run_push(); + + display.set_frame({0x22, 0x22, 0x22, 0x22}); + display.set_update_count(1); + ASSERT_FALSE(display.step()) << "expected the old plane to go out on its own first"; + display.set_frame({0x33, 0x33, 0x33, 0x33}); // drawn mid-push, before the new plane + while (!display.step()) { + } + ASSERT_EQ(bus.data[0x24].size(), 8u); + EXPECT_EQ(Bytes(bus.data[0x24].begin() + 4, bus.data[0x24].end()), (Bytes{0x33, 0x33, 0x33, 0x33})); + bus.clear(); + + display.set_frame({0x44, 0x44, 0x44, 0x44}); + display.set_update_count(2); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0x33, 0x33, 0x33})) << "old plane is not what was last sent"; +} + +/// Two full planes can take several loop iterations to send; each resumed call must continue the +/// right plane at the right byte. Planes go out in runs sized to the time slice, not row by row. +TEST(EPaperSSD1677, ResumesTheRightPlaneAfterYielding) { + // 400x100 is 5000 bytes per plane: two runs at the default 2 MHz bus + TestableSSD1677 display(400, 100); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every run overruns the time slice + display.install(&bus, 5); + + const auto old_frame = display.set_pattern(1); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + const auto new_frame = display.set_pattern(2); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_EQ(calls, 4) << "expected two runs per plane, one per call"; + EXPECT_EQ(bus.data[0x26], old_frame); + EXPECT_EQ(bus.data[0x24], new_frame); +} + +/// A requested full update takes effect when the next update starts, and pushes the whole panel +/// even if nothing was drawn. +TEST(EPaperSSD1677, RequestedFullUpdateAppliesWhenTheNextUpdateStarts) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "an update with nothing drawn should not push"; + + display.request_full_update(); + EXPECT_EQ(display.update_count(), 3) << "request changed the update in progress"; + EXPECT_TRUE(display.run_update_state()) << "requested full update did not push"; + EXPECT_EQ(display.update_count(), 0) << "requested update is not a full one"; + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "request was applied more than once"; +} + +/// Nothing a partial needs lives in controller RAM any more, so a partial push skips the reset +/// altogether; a full one still gets the hardware pulse and the software reset. +TEST(EPaperSSD1677, PartialPushSkipsTheResetAndAFullPushKeepsIt) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(1); + EXPECT_TRUE(display.reset_in(EPaperState::RESET)) << "partial push waited on a reset"; + EXPECT_TRUE(display.reset_pin.level) << "partial push pulsed the reset pin"; + EXPECT_TRUE(bus.commands.empty()) << "partial push sent a software reset"; + + display.set_update_count(0); + EXPECT_FALSE(display.reset_in(EPaperState::RESET)); + EXPECT_FALSE(display.reset_pin.level) << "full push did not pulse the reset pin"; + EXPECT_TRUE(display.reset_in(EPaperState::RESET_END)); + EXPECT_TRUE(display.reset_pin.level); + EXPECT_EQ(bus.commands, (Bytes{0x12})) << "full push did not send a software reset"; +} + +/// With every update a full one nothing is ever compared against 0x26, so no comparison frame is +/// allocated and the transfer is EPaperMono's. +TEST(EPaperSSD1677, NoComparisonFrameWhenEveryUpdateIsFull) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + EXPECT_FALSE(display.has_comparison_frame()); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 602aeb8d0e1..1678f463311 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -1,6 +1,14 @@ packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml +psram: + mode: octal + +esphome: + on_boot: + then: + - epaper_spi.full_update_next: epaper_partial + display: - platform: epaper_spi spi_id: spi_bus @@ -85,12 +93,24 @@ display: busy_pin: 37 enable_pin: 39 - platform: epaper_spi + id: epaper_partial model: seeed-ee04-mono-4.26 full_update_every: 10 # Override pins to avoid conflict with other display configs busy_pin: 43 dc_pin: 42 + # Seeed reTerminal Sticky, four-level grayscale (800x480, SSD1677) + # dc_pin/reset_pin overridden to avoid conflict with other display configs + # full_update_every is not supported by this model, so left at its default of 1 + - platform: epaper_spi + model: seeed-reterminal-sticky-gray4 + dc_pin: 45 + reset_pin: 9 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color(170, 170, 170)); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + # WeAct 2.13" 3-color e-paper (122x250, SSD1680) - platform: epaper_spi spi_id: spi_bus From eb9aae04d0e9775f9d6d362652de7e14b5b4f255 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 12:51:03 +0200 Subject: [PATCH 704/759] [rp2] Bump arduino-pico framework to 6.1.1 (#19881) --- esphome/components/rp2/__init__.py | 11 +++--- esphome/components/rp2/boards.py | 37 +++++++++++++++++++ .../components/rp2040_ble/btstack_memory.cpp | 2 +- esphome/core/defines.h | 2 +- platformio.ini | 2 +- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index a1bbf6a3d66..23346ca6f4b 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,21 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.1.0 update and the board -# JSON files it adds; replace with a release tag when one is cut +# develop-branch commit carrying the arduino-pico 6.1.0 update; it predates +# 6.1.1, so the boards that release adds have no board JSON on it yet; replace +# with a release tag when one is cut RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 1, 0), None), + "dev": (cv.Version(6, 1, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index a9ce11c33dc..08536fd0537 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1442,6 +1442,19 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "waveshare_rp2350_core": { + "LED": 39, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_lcd_0_96": { "MISO": 16, "MOSI": 19, @@ -1479,6 +1492,18 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "waveshare_rp2350_poe_eth": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_zero": { "MISO": 4, "MOSI": 3, @@ -2286,6 +2311,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "waveshare_rp2350_core": { + "name": "Waveshare RP2350 Core", + "mcu": "rp2350", + "max_pin": 47, + "die": "B", + }, "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", @@ -2304,6 +2335,12 @@ BOARDS = { "max_pin": 29, "die": "A", }, + "waveshare_rp2350_poe_eth": { + "name": "Waveshare RP2350-POE-ETH", + "mcu": "rp2350", + "max_pin": 29, + "die": "A", + }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 699555f623f..adf45eeaf7f 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.1's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index da03f0e3d16..115bae96c6e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -546,7 +546,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 1) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/platformio.ini b/platformio.ini index 102c7973b97..dd2a5724935 100644 --- a/platformio.ini +++ b/platformio.ini @@ -207,7 +207,7 @@ platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b2 platform_packages = ; The framework-arduinopico package is no longer published to the PlatformIO ; registry, so install the framework straight from the GitHub release - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.1/rp2040-6.1.1.zip framework = arduino lib_deps = From 9b9d548ad5d5857b157db53d51173b6e6a65af2b Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Tue, 29 Sep 2026 13:56:34 +0200 Subject: [PATCH 705/759] [speaker] Skip a playlist item that fails instead of retrying it forever (#19862) --- .../media_player/speaker_media_player.cpp | 26 +++++++++++++++++-- .../media_player/speaker_media_player.h | 4 +++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 9ce50d7b762..06525235d47 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -154,6 +154,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); + this->announcement_item_failed_ = false; if (media_command.file.has_value()) { this->announcement_pipeline_->start_file(playlist_item.file.value()); } else if (media_command.url.has_value()) { @@ -167,6 +168,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause this->cancel_timeout("next_media"); this->media_playlist_.clear(); + this->media_item_failed_ = false; if (this->is_paused_) { // If paused, stop the media pipeline and unpause it after confirming its stopped. This avoids playing a // short segment of the paused file before starting the new one. @@ -236,6 +238,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->announcement_pipeline_ != nullptr) { this->cancel_timeout("next_ann"); this->announcement_playlist_.clear(); + this->announcement_item_failed_ = false; this->announcement_pipeline_->stop(); this->unpause_announcement_remaining_ = 3; this->set_interval("unpause_ann", 50, [this]() { @@ -251,6 +254,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->media_pipeline_ != nullptr) { this->cancel_timeout("next_media"); this->media_playlist_.clear(); + this->media_item_failed_ = false; this->stop_and_unpause_media_(); } } @@ -351,8 +355,10 @@ void SpeakerMediaPlayer::loop() { if (this->media_pipeline_state_ == AudioPipelineState::ERROR_READING) { ESP_LOGE(TAG, "The media pipeline's file reader encountered an error."); + this->media_item_failed_ = true; } else if (this->media_pipeline_state_ == AudioPipelineState::ERROR_DECODING) { ESP_LOGE(TAG, "The media pipeline's audio decoder encountered an error."); + this->media_item_failed_ = true; } AudioPipelineState old_announcement_pipeline_state = this->announcement_pipeline_state_; @@ -362,8 +368,10 @@ void SpeakerMediaPlayer::loop() { if (this->announcement_pipeline_state_ == AudioPipelineState::ERROR_READING) { ESP_LOGE(TAG, "The announcement pipeline's file reader encountered an error."); + this->announcement_item_failed_ = true; } else if (this->announcement_pipeline_state_ == AudioPipelineState::ERROR_DECODING) { ESP_LOGE(TAG, "The announcement pipeline's audio decoder encountered an error."); + this->announcement_item_failed_ = true; } if (this->announcement_pipeline_state_ != AudioPipelineState::STOPPED) { @@ -371,7 +379,12 @@ void SpeakerMediaPlayer::loop() { } else { if (!this->announcement_playlist_.empty()) { uint32_t timeout_ms = 0; - if (old_announcement_pipeline_state == AudioPipelineState::PLAYING) { + if (this->announcement_item_failed_) { + // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline + // stops, which after an error is usually on the next loop + this->announcement_item_failed_ = false; + this->announcement_playlist_.pop_front(); + } else if (old_announcement_pipeline_state == AudioPipelineState::PLAYING) { // Finished the current announcement file if (!this->announcement_repeat_one_) { // Pop item off the playlist if repeat is disabled @@ -399,6 +412,8 @@ void SpeakerMediaPlayer::loop() { } } } else { + // Nothing left to retry + this->announcement_item_failed_ = false; if (this->is_paused_) { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { @@ -412,7 +427,12 @@ void SpeakerMediaPlayer::loop() { } else if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { if (!media_playlist_.empty()) { uint32_t timeout_ms = 0; - if (old_media_pipeline_state == AudioPipelineState::PLAYING) { + if (this->media_item_failed_) { + // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline + // stops. The flag also covers an error that happened while an announcement was playing. + this->media_item_failed_ = false; + this->media_playlist_.pop_front(); + } else if (old_media_pipeline_state == AudioPipelineState::PLAYING) { // Finished the current media file if (!this->media_repeat_one_) { // Pop item off the playlist if repeat is disabled @@ -439,6 +459,8 @@ void SpeakerMediaPlayer::loop() { } } } else { + // Nothing left to retry + this->media_item_failed_ = false; #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) { this->state = media_player::MEDIA_PLAYER_STATE_IDLE; diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6470fb925c2..889aedbc9ac 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -125,11 +125,15 @@ class SpeakerMediaPlayer final : public Component, optional media_format_; AudioPipelineState media_pipeline_state_{AudioPipelineState::STOPPED}; bool media_repeat_one_{false}; + // Set when the media pipeline reports an error, consumed when it stops, so the failed item is dropped + bool media_item_failed_{false}; uint32_t media_playlist_delay_ms_{0}; optional announcement_format_; AudioPipelineState announcement_pipeline_state_{AudioPipelineState::STOPPED}; bool announcement_repeat_one_{false}; + // Set when the announcement pipeline reports an error, consumed when it stops, so the failed item is dropped + bool announcement_item_failed_{false}; uint32_t announcement_playlist_delay_ms_{0}; QueueHandle_t media_control_command_queue_; From 757a1c42f9112a92cad9154198c7854adf08ea42 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 29 Sep 2026 14:03:19 +0100 Subject: [PATCH 706/759] [dfrobot_sen0395] Fix output_latency treating milliseconds as seconds (#19873) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/dfrobot_sen0395/__init__.py | 25 +++++++++---------- .../config/output_latency.yaml | 22 ++++++++++++++++ .../dfrobot_sen0395/test_output_latency.py | 25 +++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/dfrobot_sen0395/config/output_latency.yaml create mode 100644 tests/component_tests/dfrobot_sen0395/test_output_latency.py diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 42627a30f6f..6334e10ac5f 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -187,19 +187,18 @@ async def dfrobot_sen0395_settings_to_code( template_ = await cg.templatable(segments[7], args, cg.float_) cg.add(var.set_det_max4(template_)) if CONF_OUTPUT_LATENCY in config: - template_ = await cg.templatable( - config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DETECT], args, float - ) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds / 1000 - cg.add(var.set_delay_after_detect(template_)) - - template_ = await cg.templatable( - config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DISAPPEAR], args, float - ) - if isinstance(template_, cv.TimePeriod): - template_ = template_.total_milliseconds / 1000 - cg.add(var.set_delay_after_disappear(template_)) + latency = config[CONF_OUTPUT_LATENCY] + for key, setter in ( + (CONF_DELAY_AFTER_DETECT, var.set_delay_after_detect), + (CONF_DELAY_AFTER_DISAPPEAR, var.set_delay_after_disappear), + ): + template_ = await cg.templatable( + latency[key], + args, + float, + to_exp=lambda period: period.total_milliseconds / 1000, + ) + cg.add(setter(template_)) if CONF_SENSITIVITY in config: template_ = await cg.templatable(config[CONF_SENSITIVITY], args, cg.int8) cg.add(var.set_sensitivity(template_)) diff --git a/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml b/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml new file mode 100644 index 00000000000..a74527e4b19 --- /dev/null +++ b/tests/component_tests/dfrobot_sen0395/config/output_latency.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + on_boot: + then: + - dfrobot_sen0395.settings: + id: mmwave + output_latency: + delay_after_detect: 0.1s + delay_after_disappear: 5.0s + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 115200 + +dfrobot_sen0395: + id: mmwave diff --git a/tests/component_tests/dfrobot_sen0395/test_output_latency.py b/tests/component_tests/dfrobot_sen0395/test_output_latency.py new file mode 100644 index 00000000000..6ce6b6f9743 --- /dev/null +++ b/tests/component_tests/dfrobot_sen0395/test_output_latency.py @@ -0,0 +1,25 @@ +"""Codegen tests for dfrobot_sen0395 output_latency units.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def _latency_return(main_cpp: str, setter: str) -> str: + match = re.search( + rf"{re.escape(setter)}\(\[\]\(\) -> float \{{\s*return ([^;]+);", + main_cpp, + ) + assert match is not None, f"{setter} not found in generated main" + return match.group(1) + + +def test_output_latency_emits_seconds_not_milliseconds( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """YAML 0.1s / 5.0s must reach SetLatencyCommand as seconds, not milliseconds.""" + main_cpp = generate_main(component_config_path("output_latency.yaml")) + + assert _latency_return(main_cpp, "set_delay_after_detect") == "0.1f" + assert _latency_return(main_cpp, "set_delay_after_disappear") == "5.0f" From 627832f7ba08fc33508cc668a2cf8dd1fb3a14cc Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:36:37 +0200 Subject: [PATCH 707/759] [speaker_source] Account for playback callbacks during speaker writes (#19499) Co-authored-by: n-IA-hane --- .../speaker_source_media_player.cpp | 15 ++++++++++++--- .../components/speaker_source/test.esp32-idf.yaml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 215f3942d58..6a000b8f215 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -196,10 +196,19 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so vTaskDelay(pdMS_TO_TICKS(timeout_ms)); return 0; } + // Reserve frames before the speaker can play them and report their progress, + // then release the portion it did not accept. + const uint32_t reserved_frames = stream_info.bytes_to_frames(length); + ps.pending_frames.fetch_add(reserved_frames, std::memory_order_relaxed); size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); - if (bytes_written > 0) { - // Track frames sent to speaker for this source - ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + const uint32_t unused_frames = reserved_frames - stream_info.bytes_to_frames(bytes_written); + if (unused_frames > 0) { + // The main loop may reset pending_frames while play() is blocked. + // Clamp the release so that a concurrent reset cannot cause underflow. + uint32_t pending = ps.pending_frames.load(std::memory_order_relaxed); + while (!ps.pending_frames.compare_exchange_weak(pending, pending - std::min(pending, unused_frames), + std::memory_order_relaxed)) { + } } return bytes_written; } diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml index 5a2fd16938d..a8d47dd2538 100644 --- a/tests/components/speaker_source/test.esp32-idf.yaml +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -4,4 +4,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml -<<: !include common.yaml + speaker_source: !include common.yaml From c1303c048bd98b6c86f3948d62973f651013be87 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:50:07 +0200 Subject: [PATCH 708/759] [esp32][rp2][esp8266][libretiny]mask gpio flags (1/3) (#19393) --- esphome/components/esp32/gpio.cpp | 2 +- esphome/components/esp8266/gpio.cpp | 2 ++ esphome/components/libretiny/gpio_arduino.cpp | 2 ++ esphome/components/rp2/gpio.cpp | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 74665f3126a..a00c984f764 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -30,7 +30,7 @@ static const gpio_hal_context_t GPIO_HAL = {.dev = GPIO_HAL_GET_HW(GPIO_PORT_0)} bool ESP32InternalGPIOPin::isr_service_installed = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static gpio_mode_t flags_to_mode(gpio::Flags flags) { - flags = (gpio::Flags)(flags & ~(gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN)); + flags = flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); if (flags == gpio::FLAG_INPUT) return GPIO_MODE_INPUT; if (flags == gpio::FLAG_OUTPUT) diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index a85f054dfe9..66ac122f000 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -8,6 +8,8 @@ namespace esphome::esp8266 { static const char *const TAG = "esp8266"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_OUTPUT || flags == (gpio::FLAG_OUTPUT | gpio::FLAG_INPUT)) { return OUTPUT; } diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index b1a37cb2258..ca88835f695 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -8,6 +8,8 @@ namespace esphome::libretiny { static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_INPUT) { return INPUT; } else if (flags == gpio::FLAG_OUTPUT) { diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 0dbb124a267..b95da9bcdbc 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -9,6 +9,8 @@ namespace rp2 { static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { + flags = + flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN); if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) return INPUT; } else if (flags == gpio::FLAG_OUTPUT) { From 5fdb73aceb5df8af4c3b0689213dd5d59195ad1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:26:40 +0200 Subject: [PATCH 709/759] [libretiny] Load the precompiled header on Apple Silicon Macs (#19857) --- esphome/build_helpers/pch.py | 28 +--- esphome/platformio/pch.py.script | 74 ++++++++++- esphome/writer.py | 5 +- tests/unit_tests/build_helpers/test_pch.py | 11 -- .../unit_tests/test_platformio_pch_script.py | 122 +++++++++++++++++- tests/unit_tests/test_writer.py | 10 +- 6 files changed, 208 insertions(+), 42 deletions(-) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index bd13726d85f..ec0ff416e50 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -9,15 +9,9 @@ import os from pathlib import Path import posixpath import re -import sys from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env -from esphome.const import ( - PLATFORM_BK72XX, - PLATFORM_LN882X, - PLATFORM_NRF52, - PLATFORM_RTL87XX, -) +from esphome.const import PLATFORM_NRF52 from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -40,8 +34,12 @@ PCH_GUARD_TEXT = f"""\ #endif """ +# The cc1plus wrapper the PlatformIO script writes on arm64 macOS +PCH_CC1_DIR = "pch_cc1" + # What the PlatformIO script leaves in the project root, for cleanup PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME) +PCH_ARTIFACT_DIRS = (PCH_CC1_DIR,) # The core headers every backend precompiles PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",) @@ -53,17 +51,6 @@ PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset( } ) -# The GCC 10.3 of LibreTiny only loads its own .gch back on Linux: elsewhere -# the compiler is loaded at a new address each run, which GCC accepts from -# version 12. Remove once LibreTiny ships GCC 12 or newer -PCH_SCRIPT_LINUX_ONLY_PLATFORMS = frozenset( - { - PLATFORM_BK72XX, - PLATFORM_LN882X, - PLATFORM_RTL87XX, - } -) - # What ccache needs to cache compiles that load a .gch _CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros") @@ -103,10 +90,7 @@ def pch_script_enabled() -> bool: """Whether this PlatformIO build takes the pch script.""" from esphome.core import CORE - platform = CORE.target_platform - if platform in PCH_SCRIPT_LINUX_ONLY_PLATFORMS and sys.platform != "linux": - return False - return pch_enabled() and platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS + return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS def pch_header_text(include_headers: Iterable[str]) -> str: diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 962f5c3f2c2..960d5bd7d9e 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -1,10 +1,12 @@ import hashlib import os from pathlib import Path +import platform import posixpath import re import shlex import subprocess +import sys # pylint: disable=E0602 Import("env") # noqa: F821 @@ -29,6 +31,35 @@ _GUARD_TEXT = f"""\ #endif """ _INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE) +# Before GCC 12 a .gch only loads at the address it was saved from. Checked +# for this PlatformIO platform, whose compiler is built for a random address +_OLD_GCC_PIO_PLATFORM = "libretiny" +_MOVABLE_PCH_GCC = 12 +# Relative: compiles run in the project root +_CC1_DIR = "pch_cc1" +_CC1PLUS_WRAPPER = """\ +#!/usr/bin/env -S "{python}" -IS +# Written by ESPHome: starts cc1plus without address randomisation, so it +# loads the precompiled header. See esphome/platformio/pch.py.script. +import ctypes +import sys + +real = {real!r} +libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib") +attr = ctypes.create_string_buffer(64) +libc.posix_spawnattr_init(attr) +# POSIX_SPAWN_SETEXEC | _POSIX_SPAWN_DISABLE_ASLR +libc.posix_spawnattr_setflags(attr, ctypes.c_short(0x0040 | 0x0100)) +# What os.fsencode does, without the import +encoding = sys.getfilesystemencoding() +argv = [real.encode(), *(a.encode(encoding, "surrogateescape") for a in sys.argv[1:]), None] +libc._NSGetEnviron.restype = ctypes.POINTER(ctypes.POINTER(ctypes.c_char_p)) +# Replaces this process on success; the return value is the error number +rc = libc.posix_spawn(None, argv[0], None, attr, (ctypes.c_char_p * len(argv))(*argv), + libc._NSGetEnviron().contents) +libc.strerror.restype = ctypes.c_char_p +sys.exit("ESPHome: cannot start " + real + ": " + libc.strerror(rc).decode()) +""" _CXX_SUFFIXES = (".cpp", ".cc", ".cxx") _HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc") _INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter") @@ -81,6 +112,35 @@ def _src_envs(src_dir: Path) -> list: return list(found.values()) or [projenv] # noqa: F821 +def _driver_output(cxx: str, option: str) -> str: + result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + return result.stdout.strip() + + +def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): + """Extra driver flags for an old GCC, or None where its .gch cannot be + used. Linux loads it as is. arm64 macOS loads every program at a random + address, so there the driver gets a -B directory whose cc1plus starts + the real one without that randomisation.""" + if pio_platform != _OLD_GCC_PIO_PLATFORM or sys.platform == "linux": + return [] + major = _driver_output(cxx, "-dumpversion").partition(".")[0] + if major.isdigit() and int(major) >= _MOVABLE_PCH_GCC: + return [] + if sys.platform != "darwin" or platform.machine() != "arm64": + return None + # The driver prints the bare name when it has no cc1plus of its own + real = _driver_output(cxx, "-print-prog-name=cc1plus") + if not os.path.isabs(real): + return None + wrapper = proj_dir / _CC1_DIR / "cc1plus" + wrapper.parent.mkdir(exist_ok=True) + real = os.path.normpath(real) + wrapper.write_text(_CC1PLUS_WRAPPER.format(python=sys.executable, real=real)) + wrapper.chmod(0o755) + return [f"-B{_CC1_DIR}/"] + + def _setup_pch() -> None: if projenv is None: if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821 @@ -97,6 +157,11 @@ def _setup_pch() -> None: src_envs = _src_envs(src_dir) cxx = src_envs[0].subst("$CXX") + pio_platform = env.PioPlatform() # noqa: F821 + extra = _old_gcc_flags(proj_dir, pio_platform.name, cxx) + if extra is None: + print("ESPHome: this compiler cannot load a precompiled header here; compiling without it") + return # The header holds the -include entries itself, so the .gch compile must # not see them; consumers keep theirs, which the .gch then satisfies flags = [] @@ -133,9 +198,8 @@ def _setup_pch() -> None: digest.update(flags_id.encode()) # GCC does not check a .gch against its headers and PlatformIO package # paths carry no version - platform = env.PioPlatform() # noqa: F821 - for package in sorted(platform.packages): - digest.update(f"{package}={platform.get_package_version(package)}\0".encode()) + for package in sorted(pio_platform.packages): + digest.update(f"{package}={pio_platform.get_package_version(package)}\0".encode()) # Libraries carry their version in lib_deps digest.update(repr(env.GetProjectOption("lib_deps", [])).encode()) # noqa: F821 closure = _include_closure(src_dir, headers) @@ -183,7 +247,7 @@ def _setup_pch() -> None: header.write_text(_GUARD_TEXT, encoding="utf-8") source.write_text(content, encoding="utf-8") result = subprocess.run( # noqa: PLW1510 - [cxx, "-x", "c++-header", *flags, "-c", str(source), "-o", str(gch)], + [cxx, "-x", "c++-header", *flags, *extra, "-c", str(source), "-o", str(gch)], cwd=proj_dir, capture_output=True, text=True, @@ -196,7 +260,7 @@ def _setup_pch() -> None: # Prepended: GCC only loads a .gch for the first -include for src_env in src_envs: - src_env.Prepend(CXXFLAGS=_CONSUMER_FLAGS) + src_env.Prepend(CXXFLAGS=[*extra, *_CONSUMER_FLAGS]) print( "ESPHome: Compiling with a precompiled header " "(set ESPHOME_PCH_ENABLE=0 to disable)" diff --git a/esphome/writer.py b/esphome/writer.py index 9f2ff85cff5..0bd485bfd31 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -611,10 +611,13 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False): _LOGGER.info("Deleting %s", idf_path) rmtree(idf_path) # The PlatformIO pch files live in the project root - from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES + from esphome.build_helpers.pch import PCH_ARTIFACT_DIRS, PCH_ARTIFACT_NAMES for name in PCH_ARTIFACT_NAMES: CORE.relative_build_path(name).unlink(missing_ok=True) + for name in PCH_ARTIFACT_DIRS: + if (pch_dir := CORE.relative_build_path(name)).is_dir(): + rmtree(pch_dir) # idedata caches live under the data dir, not the build path; globbed # so a future backend suffix cannot drift out of clean-all diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index 0ea82157381..a03b9c3a11d 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -113,23 +113,12 @@ def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None: "platform", ["bk72xx", "esp32", "esp8266", "ln882x", "rp2", "rtl87xx"] ) def test_pch_script_enabled(platform: str, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(pch.sys, "platform", "linux") CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} assert pch.pch_script_enabled() monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") assert not pch.pch_script_enabled() -@pytest.mark.parametrize("host", ["darwin", "win32"]) -@pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_LINUX_ONLY_PLATFORMS)) -def test_pch_script_linux_only_platform( - platform: str, host: str, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(pch.sys, "platform", host) - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} - assert not pch.pch_script_enabled() - - @pytest.mark.parametrize("platform", sorted(pch.PCH_SCRIPT_EXCLUDED_PLATFORMS)) def test_pch_script_excluded_platform(platform: str) -> None: CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index bccfc17477b..e7ea1a34201 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -6,6 +6,8 @@ from collections.abc import Callable import os from pathlib import Path import stat +import subprocess +import sys from types import SimpleNamespace from unittest.mock import patch @@ -22,6 +24,7 @@ _SCRIPT = Path(toolchain.__file__).parent / "pch.py.script" class _FakePlatform: + name = "fake" packages = {"framework-x": {}, "toolchain-y": {}} def get_package_version(self, name: str) -> str | None: @@ -72,7 +75,13 @@ class _FakeSConsEnv(dict): def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: """A compiler stand-in that records its argv and writes the -o target.""" cxx = tmp_path / "fake-gxx" + # As GCC: the program's path when it exists next to the driver, else its + # name; the version from FAKE_GCC_VERSION body = ( + 'case "$1" in -print-prog-name=*) n=${1#*=};' + ' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";' + ' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;' + ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;; esac\n' 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' ) if fail: @@ -93,6 +102,7 @@ def _run_script( name: str = "dev", platform_cls: type[_FakePlatform] = _FakePlatform, build_files: Callable[[tuple], list] | None = None, + cxx: Path | None = None, ) -> _FakeSConsEnv: proj = tmp_path / name src = proj / "src" @@ -101,7 +111,8 @@ def _run_script( (src / "esphome" / "core" / "pch_prefix.h").write_text( '#include "esphome/core/defines.h"\n' ) - cxx = _fake_cxx(tmp_path, fail=fail) + if cxx is None: + cxx = _fake_cxx(tmp_path, fail=fail) args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls) # Distinct objects: the -include flags must land on projenv only global_env = _FakeSConsEnv(*args) @@ -137,6 +148,114 @@ def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None assert scons_env.global_env.prepended == [] +class _LibreTinyPlatform(_FakePlatform): + name = "libretiny" + + +def _run_on_host( + tmp_path: Path, + host: str, + machine: str, + platform_cls: type[_FakePlatform], + cxx: Path | None = None, + gcc_version: str = "10.3.1", +) -> _FakeSConsEnv: + with ( + patch.object(sys, "platform", host), + patch("platform.machine", return_value=machine), + ): + return _run_script( + tmp_path, + platform_cls=platform_cls, + cxx=cxx, + env_vars={"FAKE_GCC_VERSION": gcc_version}, + ) + + +def _fake_toolchain(tmp_path: Path) -> tuple[Path, Path]: + """A driver in bin/ and a cc1plus that records its argv in libexec/.""" + toolchain = tmp_path / "toolchain" + (toolchain / "bin").mkdir(parents=True) + cxx = _fake_cxx(toolchain / "bin") + real = toolchain / "libexec" / "gcc" / "arm-none-eabi" / "10.3.1" / "cc1plus" + real.parent.mkdir(parents=True) + real.write_text('#!/bin/sh\nprintf \'%s\\n\' "$@" > "$0.argv"\n') + real.chmod(0o755) + return cxx, real + + +def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None: + """The LibreTiny .gch compile and the consumers get a -B directory + holding a cc1plus that starts the real one.""" + cxx, real = _fake_toolchain(tmp_path) + scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + assert wrapper.stat().st_mode & stat.S_IXUSR + assert repr(str(real)) in wrapper.read_text(encoding="utf-8") + argv = Path(f"{cxx}.argv").read_text(encoding="utf-8").split("\n") + assert f"-B{pch.PCH_CC1_DIR}/" in argv + assert scons_env.prepended == [f"-B{pch.PCH_CC1_DIR}/", *pch.pch_consumer_flags()] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only") +def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None: + cxx, real = _fake_toolchain(tmp_path) + _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + result = subprocess.run( + [str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + assert Path(f"{real}.argv").read_text(encoding="utf-8") == "-quiet\nx.cpp\n" + + +def test_pch_script_gcc10_without_cc1plus_builds_plainly( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A driver without a cc1plus of its own gets no wrapper and no header.""" + scons_env = _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform) + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + assert "compiling without it" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("host", "machine"), [("darwin", "x86_64"), ("win32", "AMD64")] +) +def test_pch_script_gcc10_skipped_elsewhere( + tmp_path: Path, host: str, machine: str, capsys: pytest.CaptureFixture[str] +) -> None: + """Where the GCC 10 .gch cannot load, the build runs without it.""" + scons_env = _run_on_host(tmp_path, host, machine, _LibreTinyPlatform) + assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists() + assert scons_env.prepended == [] + assert "compiling without it" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("host", "machine", "platform_cls", "gcc_version"), + [ + ("darwin", "arm64", _FakePlatform, "10.3.1"), + ("linux", "aarch64", _LibreTinyPlatform, "10.3.1"), + # From GCC 12 the .gch loads at any address, so the rule retires itself + ("darwin", "arm64", _LibreTinyPlatform, "12.2.0"), + ("win32", "AMD64", _LibreTinyPlatform, "14.2.0"), + ], +) +def test_pch_script_no_wrapper_where_the_gch_loads( + tmp_path: Path, + host: str, + machine: str, + platform_cls: type[_FakePlatform], + gcc_version: str, +) -> None: + scons_env = _run_on_host( + tmp_path, host, machine, platform_cls, gcc_version=gcc_version + ) + assert not (tmp_path / "dev" / pch.PCH_CC1_DIR).exists() + assert scons_env.prepended == pch.pch_consumer_flags() + + def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: """The script cannot import esphome, so its copies are pinned.""" namespace: dict[str, object] = { @@ -152,6 +271,7 @@ def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: assert namespace["_CONSUMER_FLAGS"] == pch.pch_consumer_flags() assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern + assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 432a2065bff..a1ae7f9ec77 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -684,11 +684,17 @@ def test_clean_build_partial_removes_pch_artifacts( ) -> None: """The PlatformIO pch sidecars live at the project root and must go in a partial clean, like the native backend's under .pioenvs.""" - from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES as names + from esphome.build_helpers.pch import ( + PCH_ARTIFACT_DIRS as dirs, + PCH_ARTIFACT_NAMES as names, + ) assert "esphome_pch.h.gch" in names for name in names: (tmp_path / name).write_text("x") + for name in dirs: + (tmp_path / name).mkdir() + (tmp_path / name / "cc1plus").write_text("x") mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs" mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" mock_core.relative_build_path.side_effect = lambda name: tmp_path / name @@ -696,7 +702,7 @@ def test_clean_build_partial_removes_pch_artifacts( clean_build() - for name in names: + for name in (*names, *dirs): assert not (tmp_path / name).exists() From 5be41dec15d408855cf71e0ce757707ed1f1079e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:31:24 +0200 Subject: [PATCH 710/759] [esp8266] Use our own compiler builds for the native toolchain (#19849) --- esphome/__main__.py | 2 +- esphome/arduino8266/framework.py | 80 ++++++++++++++-- esphome/components/esp8266/__init__.py | 11 ++- esphome/platformio/registry.py | 44 ++++++--- .../unit_tests/test_arduino8266_framework.py | 96 ++++++++++++++++++- tests/unit_tests/test_main.py | 20 ++++ tests/unit_tests/test_platformio_registry.py | 79 ++++++++++++++- 7 files changed, 301 insertions(+), 31 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 8ed97ccae88..e67bc42764a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -838,7 +838,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: # Keep this here, NOT in codegen: config-hash and --only-generate must keep # working on machines that cannot run the toolchain. - if CORE.is_esp8266: + if CORE.is_esp8266 and CORE.using_toolchain_platformio: from esphome.components.esp8266 import check_rosetta check_rosetta() diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index d32d61375bb..0c411a9d5d9 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -4,11 +4,12 @@ Artifacts land in a machine-global cache (shared across projects, like the ESP-IDF install in ``esphome.espidf.framework``): /arduino8266/frameworks// framework-arduinoespressif8266 - /arduino8266/toolchains// toolchain-xtensa (gcc 10.3) + /arduino8266/toolchains// xtensa-lx106-elf gcc 10.3 -Packages come from the PlatformIO registry (identical bits to the PlatformIO -backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes -from PATH or the ninja PyPI wheel. +The framework comes from the PlatformIO registry, the toolchain from +https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/; +``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes from PATH +or the ninja PyPI wheel. """ from __future__ import annotations @@ -22,13 +23,45 @@ from esphome.build_helpers.ninja import find_ninja from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path from esphome.core import EsphomeError, Version from esphome.framework_helpers import str_to_lst_of_str -from esphome.platformio.registry import install_package, prefetch_packages +from esphome.platformio.registry import ( + Download, + Resolver, + get_systype, + install_package, + prefetch_packages, +) FRAMEWORK_PACKAGE = "framework-arduinoespressif8266" -TOOLCHAIN_PACKAGE = "toolchain-xtensa" +TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf" # gcc 10.3, the toolchain Arduino core 3.x builds with; the build # generator's compile flags are tuned to it. -TOOLCHAIN_VERSION = "2.100300.220621" +TOOLCHAIN_VERSION = "10.3.0-esphome.2" +_TOOLCHAIN_RELEASES = ( + "https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/" +) +# Registry system tag -> (sha256, size) of that host's archive +TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = { + "darwin_arm64": ( + "849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201", + 60830321, + ), + "darwin_x86_64": ( + "ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897", + 64149487, + ), + "linux_aarch64": ( + "60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6", + 67573397, + ), + "linux_x86_64": ( + "1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4", + 68431336, + ), + "windows_amd64": ( + "af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b", + 67664137, + ), +} ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str( os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "") @@ -74,6 +107,22 @@ def get_toolchain_path() -> Path: return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION +def toolchain_download() -> Download: + """The toolchain archive for the current host.""" + systype = get_systype() + if (build := TOOLCHAIN_BUILDS.get(systype)) is None: + raise EsphomeError( + f"There is no ESP8266 toolchain for this system ({systype}); " + f"supported systems are {', '.join(sorted(TOOLCHAIN_BUILDS))}. " + "Either set 'toolchain: platformio' under 'esp8266:', or point " + "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS at a toolchain archive" + ) + sha256, size = build + archive = f"{TOOLCHAIN_PACKAGE}-{TOOLCHAIN_VERSION}-{systype}.tar.gz" + url = f"{_TOOLCHAIN_RELEASES}download/{TOOLCHAIN_VERSION}/{archive}" + return Download(url, sha256, size) + + class InstalledPaths(NamedTuple): """Locations of the installed framework, toolchain, and ninja binary.""" @@ -115,10 +164,23 @@ def check_and_install(framework_version: Version) -> InstalledPaths: ("bin", "xtensa-lx106-elf"), ), ) + # Resolved only when a download is needed, so an installed toolchain + # keeps working on a host without a build; a mirror override wins + resolvers: dict[str, Resolver] = {} + if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS: + resolvers[TOOLCHAIN_PACKAGE] = toolchain_download # Fetch both archives at once; the installs below verify and extract - prefetch_packages([spec[:4] for spec in specs], downloads_dir) + prefetch_packages([spec[:4] for spec in specs], downloads_dir, resolvers) for name, version, dest, mirrors, expect in specs: - install_package(name, version, dest, mirrors, downloads_dir, expect=expect) + install_package( + name, + version, + dest, + mirrors, + downloads_dir, + expect=expect, + resolve=resolvers.get(name), + ) return InstalledPaths( framework=framework_path, toolchain=toolchain_path, ninja=ninja_path ) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 808d7ca7a9d..1016c49152b 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -314,8 +314,8 @@ CONFIG_SCHEMA = cv.All( def check_rosetta() -> None: """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. - There is no native arm64 build of the xtensa-lx106 toolchain; on Apple - Silicon it runs under Rosetta 2, which macOS updates can remove. + PlatformIO's xtensa-lx106 toolchain has no arm64 build; on Apple Silicon + it runs under Rosetta 2, which macOS updates can remove. """ if not IS_MACOS or platform.machine() != "arm64": return @@ -330,9 +330,10 @@ def check_rosetta() -> None: return # arch(1) unavailable; let the build proceed if result.returncode != 0: raise EsphomeError( - "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " - "compiler that requires Rosetta 2, which is not installed on " - "this system. Install it with:\n" + "ESP8266 builds with PlatformIO on Apple Silicon Macs use an Intel " + "(x86_64) compiler that requires Rosetta 2, which is not installed " + "on this system. Either set 'toolchain: arduino' under 'esp8266:', " + "which needs no Rosetta, or install it with:\n" " softwareupdate --install-rosetta --agree-to-license" ) diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index 75df82da0e9..cab536c8da7 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -31,6 +31,18 @@ _REGISTRY_URL = ( ) +class Download(NamedTuple): + """A package archive to fetch and verify.""" + + url: str + sha256: str + size: int | None + + +# Looks a package's download up; called only when a download is needed +Resolver = Callable[[], Download] + + def get_systype() -> str: """The registry system tag for the current host. @@ -56,7 +68,7 @@ def get_systype() -> str: @cache -def registry_download(package: str, version: str) -> tuple[str, str, int | None]: +def registry_download(package: str, version: str) -> Download: """Resolve a package's download URL, sha256, and size via the registry. The metadata fetch goes through ``http_request``/``fetch_with_retry`` @@ -144,7 +156,7 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None] f"The package registry returned no download URL for " f"{package} {version}" ) - return (url, sha256, file.get("size")) + return Download(url, sha256, file.get("size")) raise EsphomeError( f"No {package} {version} build for this platform ({systype})" ) @@ -177,17 +189,20 @@ def _archive_path(downloads_dir: Path, name: str, version: str) -> Path: return downloads_dir / f"{name}-{version}" -def _already_installed(dest: Path) -> bool: +def is_installed(dest: Path) -> bool: """Whether ``dest`` holds a completed install (extraction marker).""" return (dest / ".esphome_extracted").is_file() def prefetch_packages( - packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path + packages: list[tuple[str, str, Path, list[str]]], + downloads_dir: Path, + resolvers: dict[str, Resolver] | None = None, ) -> None: """Download pending package archives in parallel under one combined bar. - ``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely + ``packages`` holds ``(name, version, dest, mirrors)`` per package; + ``resolvers`` replaces the registry lookup by name. Purely an optimization: ``install_package`` verifies every archive and re-downloads anything this pass left unfinished. Mirror overrides and registry entries without a size stay on the sequential path so its @@ -200,15 +215,18 @@ def prefetch_packages( pending: list[_PendingArchive] = [] seen: set[Path] = set() for name, version, dest, mirrors in packages: - if mirrors or (dest / ".esphome_extracted").is_file(): + if mirrors or is_installed(dest): continue archive = _archive_path(downloads_dir, name, version) if archive in seen: # A duplicate entry would race itself between two workers continue seen.add(archive) + resolve = (resolvers or {}).get(name) or partial( + registry_download, name, version + ) try: - url, sha256, size = registry_download(name, version) + url, sha256, size = resolve() except EsphomeError as err: # The sequential install reports the real failure with context _LOGGER.debug("Prefetch resolve for %s failed: %s", name, err) @@ -234,7 +252,7 @@ def prefetch_packages( if done := downloaded_bytes(entry.archive, entry.size): return done # The holder deletes the archive once it has installed it - return entry.size if _already_installed(entry.dest) else 0 + return entry.size if is_installed(entry.dest) else 0 lock = FileLock(f"{entry.dest}.lock", fallback_to_soft=False) try: @@ -245,7 +263,7 @@ def prefetch_packages( _LOGGER.debug("Leaving %s to its current downloader", entry.name) return try: - if _already_installed(entry.dest): + if is_installed(entry.dest): # A concurrent build installed it while we waited; a # re-download would orphan a fresh copy in downloads_dir tracker(entry.size) @@ -282,13 +300,15 @@ def install_package( mirrors: list[str], downloads_dir: Path, expect: Collection[str], + resolve: Resolver | None = None, ) -> None: """Download, verify, and extract one package if not already installed. The registry path is integrity-checked against the sha256 the registry publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}`` substitution) is trusted as configured. ``downloads_dir`` holds the - archive between runs so an interrupted download resumes. + archive between runs so an interrupted download resumes. ``resolve`` + replaces the registry lookup. """ if not expect: # Layout validation before marker.touch() is the only guard against @@ -323,7 +343,9 @@ def install_package( mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive ) else: - url, sha256, size = registry_download(name, version) + url, sha256, size = ( + resolve() if resolve else registry_download(name, version) + ) download_with_resume(url, archive, sha256=sha256, size=size) _LOGGER.info("Extracting %s ...", name) archive_extract_all(archive, dest, progress_header="Extracting") diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index ab11c41afb4..6f43f0bf40c 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -54,6 +54,91 @@ def test_tools_path_default_and_prefix(tmp_path: Path) -> None: assert path != Path.cwd() +def test_toolchain_builds_are_pinned() -> None: + """A release must not be pinned without its checksums.""" + for sha256, size in framework.TOOLCHAIN_BUILDS.values(): + assert len(sha256) == 64 + assert size > 0 + + +def test_toolchain_download() -> None: + sha256, size = framework.TOOLCHAIN_BUILDS["darwin_arm64"] + with ( + patch.object(framework, "TOOLCHAIN_VERSION", "1.2.3"), + patch.object(framework, "get_systype", return_value="darwin_arm64"), + ): + download = framework.toolchain_download() + releases = "https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/" + archive = "toolchain-xtensa-lx106-elf-1.2.3-darwin_arm64.tar.gz" + assert download == (f"{releases}download/1.2.3/{archive}", sha256, size) + + +def test_toolchain_download_unsupported_system() -> None: + with ( + patch.object(framework, "get_systype", return_value="linux_armv7l"), + pytest.raises( + EsphomeError, match=r"linux_armv7l.*darwin_arm64.*toolchain: platformio" + ), + ): + framework.toolchain_download() + + +def _fake_framework(tmp_path: Path) -> None: + """The layout install_package expects of an installed framework.""" + for sub in ("cores/esp8266", "tools/sdk", "libraries"): + (tmp_path / "frameworks" / "3.30102.0" / sub).mkdir(parents=True) + (tmp_path / "frameworks" / "3.30102.0" / ".esphome_extracted").touch() + + +def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None: + """With a mirror override an unsupported host can bring its own toolchain.""" + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", ["http://m"]), + patch.object(framework, "install_package") as mock_install, + patch.object(framework, "prefetch_packages") as mock_prefetch, + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + ): + framework.check_and_install(cv.Version(3, 1, 2)) + assert mock_prefetch.call_args.args[2] == {} + assert mock_install.call_args_list[1].kwargs["resolve"] is None + + +def test_check_and_install_installed_toolchain_on_unsupported_host( + tmp_path: Path, +) -> None: + """The host is only checked when the toolchain must be downloaded.""" + _fake_framework(tmp_path) + toolchain = tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION + for sub in ("bin", "xtensa-lx106-elf"): + (toolchain / sub).mkdir(parents=True) + (toolchain / ".esphome_extracted").touch() + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "get_systype", return_value="linux_armv7l"), + patch.object(framework, "prefetch_packages"), + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + ): + paths = framework.check_and_install(cv.Version(3, 1, 2)) + assert paths.toolchain == toolchain + + +def test_check_and_install_unsupported_host_without_toolchain_raises( + tmp_path: Path, +) -> None: + """Without an installed toolchain the download is needed, and the host + error names the ways out.""" + _fake_framework(tmp_path) + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "get_systype", return_value="linux_armv7l"), + patch.object(framework, "prefetch_packages"), + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + pytest.raises(EsphomeError, match=r"linux_armv7l.*toolchain: platformio"), + ): + framework.check_and_install(cv.Version(3, 1, 2)) + + def test_check_and_install_returns_paths(tmp_path: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), @@ -76,7 +161,10 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, tmp_path / "downloads", ) - assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries") + assert fw_call.kwargs == { + "expect": ("cores/esp8266", "tools/sdk", "libraries"), + "resolve": None, + } assert tc_call.args == ( framework.TOOLCHAIN_PACKAGE, framework.TOOLCHAIN_VERSION, @@ -84,7 +172,10 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, tmp_path / "downloads", ) - assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf") + assert tc_call.kwargs == { + "expect": ("bin", "xtensa-lx106-elf"), + "resolve": framework.toolchain_download, + } # The prefetch sees the same package specs as the installs assert mock_prefetch.call_args.args == ( [ @@ -102,6 +193,7 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: ), ], tmp_path / "downloads", + {framework.TOOLCHAIN_PACKAGE: framework.toolchain_download}, ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2ed61e5d26e..bf5d1566c05 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5999,6 +5999,26 @@ def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None: mock_check.assert_called_once() +def test_compile_program_esp8266_native_skips_rosetta_check(tmp_path: Path) -> None: + """The native toolchain has an arm64 macOS build, so nothing is probed.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + + with ( + patch("esphome.components.esp8266.check_rosetta") as mock_check, + patch( + "esphome.components.esp8266.run_compile", + side_effect=EsphomeError("compile reached"), + ), + pytest.raises(EsphomeError, match="compile reached"), + ): + compile_program(MockArgs(), config) + + mock_check.assert_not_called() + + def test_compile_program_skips_rosetta_check_on_other_platforms( tmp_path: Path, mock_compile_build_info_run_compile: Mock, diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 9d5f6c4ce59..9f1c1408469 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -311,6 +311,56 @@ def test_install_package_downloads_via_registry(tmp_path: Path) -> None: assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42} +def test_install_package_downloads_pinned(tmp_path: Path) -> None: + """A resolver's download is verified like a registry one, with no lookup.""" + dest = tmp_path / "pkg" + pinned = registry.Download("http://y/pinned.tar.gz", "def456", 7) + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "registry_download") as mock_registry, + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + [], + tmp_path / "dl", + expect=("payload",), + resolve=lambda: pinned, + ) + mock_registry.assert_not_called() + assert mock_download.call_args[0][0] == "http://y/pinned.tar.gz" + assert mock_download.call_args[1] == {"sha256": "def456", "size": 7} + + +def test_install_package_mirror_wins_over_pinned(tmp_path: Path) -> None: + """A mirror override replaces the pinned download.""" + dest = tmp_path / "pkg" + with ( + patch.object(registry, "download_from_mirrors") as mock_mirrors, + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + ["http://mirror/{VERSION}"], + tmp_path / "dl", + expect=("payload",), + resolve=lambda: registry.Download("http://y/pinned.tar.gz", "def456", 7), + ) + mock_mirrors.assert_called_once() + mock_download.assert_not_called() + + def test_install_package_validates_expected_layout(tmp_path: Path) -> None: """The success marker is only written when the extracted tree is usable.""" dest = tmp_path / "pkg" @@ -535,6 +585,29 @@ def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None assert callable(call[1]["progress"]) +def test_prefetch_packages_uses_pinned_download(tmp_path: Path) -> None: + """A package with a resolver skips the registry; the rest use it.""" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10}) + ) as mock_registry, + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + {"b": lambda: registry.Download("http://y/b.tar.gz", "def456", 20)}, + ) + mock_registry.assert_called_once_with("a", "1.0") + calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0]) + assert [c[0][0] for c in calls] == ["http://x/a.tar.gz", "http://y/b.tar.gz"] + assert calls[1][1]["sha256"] == "def456" + assert calls[1][1]["size"] == 20 + + def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: """A dest whose marker appeared while the worker waited on the lock is already installed; re-downloading would orphan an archive copy.""" @@ -620,13 +693,13 @@ def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder( mock_download.assert_not_called() -def test_already_installed_probe(tmp_path: Path) -> None: +def test_is_installed_probe(tmp_path: Path) -> None: """Both arms of the marker probe the prefetch worker keys on.""" dest = tmp_path / "pkg" dest.mkdir() - assert registry._already_installed(dest) is False + assert registry.is_installed(dest) is False (dest / ".esphome_extracted").touch() - assert registry._already_installed(dest) is True + assert registry.is_installed(dest) is True def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None: From a7e0afeb46367cfe6412f76c584a7576f78dc85b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:38:51 +0200 Subject: [PATCH 711/759] [esp8266] Use our own Arduino core builds for the native toolchain (#19876) --- .github/actions/cache-arduino8266/action.yml | 2 +- esphome/arduino8266/framework.py | 79 +++++++++--------- esphome/components/esp8266/__init__.py | 37 ++++++--- .../esp8266/test_framework_version.py | 31 ++++++- .../esp8266/test_toolchain_validation.py | 19 +++-- .../unit_tests/test_arduino8266_framework.py | 83 ++++++++++--------- 6 files changed, 152 insertions(+), 99 deletions(-) diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml index affe62b5aa2..c9e194d667e 100644 --- a/.github/actions/cache-arduino8266/action.yml +++ b/.github/actions/cache-arduino8266/action.yml @@ -22,7 +22,7 @@ runs: # cached path cannot diverge. echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV" . venv/bin/activate - key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")') + key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{t}")') [ -n "$key" ] || exit 1 echo "key=$key" >> "$GITHUB_OUTPUT" - name: Cache the native toolchain (write on dev) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 0c411a9d5d9..a8f8c65c066 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -3,13 +3,12 @@ Artifacts land in a machine-global cache (shared across projects, like the ESP-IDF install in ``esphome.espidf.framework``): - /arduino8266/frameworks// framework-arduinoespressif8266 - /arduino8266/toolchains// xtensa-lx106-elf gcc 10.3 + /arduino8266/frameworks// the Arduino core + /arduino8266/toolchains// xtensa-lx106-elf gcc 10.3 -The framework comes from the PlatformIO registry, the toolchain from -https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/; -``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes from PATH -or the ninja PyPI wheel. +Both come from esphome-libs releases pinned below; +``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs, with ``{VERSION}`` +standing for the release tag. ninja comes from PATH or the ninja PyPI wheel. """ from __future__ import annotations @@ -31,7 +30,30 @@ from esphome.platformio.registry import ( prefetch_packages, ) -FRAMEWORK_PACKAGE = "framework-arduinoespressif8266" +FRAMEWORK_PACKAGE = "arduino-esp8266" +_FRAMEWORK_RELEASES = "https://github.com/esphome-libs/arduino-esp8266/releases/" + + +class FrameworkRelease(NamedTuple): + tag: str + sha256: str + size: int + + def download(self) -> Download: + archive = f"{FRAMEWORK_PACKAGE}-{self.tag}.tar.gz" + url = f"{_FRAMEWORK_RELEASES}download/{self.tag}/{archive}" + return Download(url, self.sha256, self.size) + + +# Arduino core version -> its build in esphome-libs/arduino-esp8266 +FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { + Version(3, 1, 2): FrameworkRelease( + "3.1.2-esphome.1", + "e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582", + 37189311, + ), +} + TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf" # gcc 10.3, the toolchain Arduino core 3.x builds with; the build # generator's compile flags are tuned to it. @@ -77,30 +99,18 @@ def get_arduino8266_tools_path() -> Path: return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) -# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0 -MIN_FRAMEWORK_VERSION = Version(3, 1, 1) - - -def framework_package_version(ver: Version) -> str: - """Map an Arduino core version to its registry package version (3.1.2 -> - 3.30102.0; the leading 3 is the package major). - - Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION. - """ - if ver.major > 3: +def framework_release(version: Version) -> FrameworkRelease: + if (release := FRAMEWORK_RELEASES.get(version)) is None: raise EsphomeError( - f"Arduino core {ver} is not supported yet; " - "the newest known core series is 3.x" + f"'toolchain: arduino' has no build of Arduino core {version}; " + f"available: {', '.join(str(v) for v in FRAMEWORK_RELEASES)}. " + "Use one of those or 'toolchain: platformio'" ) - if ver.major < 3: - raise EsphomeError( - f"Arduino core {ver} is not supported; ESPHome requires core 3.x" - ) - return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + return release -def get_framework_path(package_version: str) -> Path: - return get_arduino8266_tools_path() / "frameworks" / package_version +def get_framework_path(tag: str) -> Path: + return get_arduino8266_tools_path() / "frameworks" / tag def get_toolchain_path() -> Path: @@ -133,24 +143,17 @@ class InstalledPaths(NamedTuple): def check_and_install(framework_version: Version) -> InstalledPaths: """Ensure framework, toolchain, and ninja are installed; return their paths.""" - if framework_version < MIN_FRAMEWORK_VERSION: - # Config validation enforces this too; keep the module honest when - # called directly. - raise EsphomeError( - f"The native toolchain requires the Arduino core " - f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}" - ) + release = framework_release(framework_version) # Probe the cheap local dependency before ~110 MB of downloads ninja_path = find_ninja() - package_version = framework_package_version(framework_version) - framework_path = get_framework_path(package_version) + framework_path = get_framework_path(release.tag) downloads_dir = get_arduino8266_tools_path() / "downloads" toolchain_path = get_toolchain_path() # One spec per package: the prefetch and the installs must agree specs = ( ( FRAMEWORK_PACKAGE, - package_version, + release.tag, framework_path, ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, ("cores/esp8266", "tools/sdk", "libraries"), @@ -167,6 +170,8 @@ def check_and_install(framework_version: Version) -> InstalledPaths: # Resolved only when a download is needed, so an installed toolchain # keeps working on a host without a build; a mirror override wins resolvers: dict[str, Resolver] = {} + if not ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS: + resolvers[FRAMEWORK_PACKAGE] = release.download if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS: resolvers[TOOLCHAIN_PACKAGE] = toolchain_download # Fetch both archives at once; the installs below verify and extract diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1016c49152b..0e5303e8df1 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -118,15 +118,14 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType: """Constraints of the native (non-PlatformIO) Arduino toolchain.""" if not CORE.using_toolchain_arduino: return config - from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION + from esphome.arduino8266.framework import framework_release conf = config[CONF_FRAMEWORK] version = cv.Version.parse(conf[CONF_VERSION]) - if version < MIN_FRAMEWORK_VERSION: - raise cv.Invalid( - "'toolchain: arduino' requires framework version " - f"{MIN_FRAMEWORK_VERSION} or newer" - ) + try: + framework_release(version) + except EsphomeError as err: + raise cv.Invalid(str(err), path=[CONF_FRAMEWORK, CONF_VERSION]) from err # platform_version is a PlatformIO concept; drop it, warning when a # custom pin is discarded if ( @@ -137,6 +136,8 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType: "'platform_version' is ignored by 'toolchain: arduino'; the native " "toolchain downloads the framework and compiler directly" ) + # The native path fetches its own build; source may only be the + # PlatformIO default the schema filled in if conf[CONF_SOURCE] != _format_framework_arduino_version(version): raise cv.Invalid( "'toolchain: arduino' does not support a custom framework source; " @@ -174,16 +175,27 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: ] +def _framework_package_version(ver: cv.Version) -> str: + """Map an Arduino core version to its PlatformIO registry package version + (3.1.2 -> 3.30102.0; the leading 3 is the package major).""" + if ver.major > 3: + raise EsphomeError( + f"Arduino core {ver} is not supported yet; " + "the newest known core series is 3.x" + ) + if ver.major < 3: + raise EsphomeError( + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" + ) + return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + + def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 - # Same encoding the native toolchain uses for its package download, so a - # custom-source check against this value cannot drift from what it fetches. - from esphome.arduino8266.framework import framework_package_version - try: - return f"~{framework_package_version(ver)}" + return f"~{_framework_package_version(ver)}" except EsphomeError as err: # Anchor the 4.x rejection to the framework version line instead of # aborting with a bare traceback-level error @@ -194,7 +206,8 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # * New framework historically have had some regressions, especially for WiFi. # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked -# * For all constants below, update platformio.ini (in this repo) +# * For all constants below, update platformio.ini (in this repo) and +# FRAMEWORK_RELEASES in esphome/arduino8266/framework.py # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases diff --git a/tests/unit_tests/components/esp8266/test_framework_version.py b/tests/unit_tests/components/esp8266/test_framework_version.py index 0107aff8dd3..11590f77cf2 100644 --- a/tests/unit_tests/components/esp8266/test_framework_version.py +++ b/tests/unit_tests/components/esp8266/test_framework_version.py @@ -2,9 +2,14 @@ import pytest -from esphome.components.esp8266 import _arduino_check_versions +from esphome.components.esp8266 import ( + _arduino_check_versions, + _format_framework_arduino_version, + _framework_package_version, +) import esphome.config_validation as cv from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION +from esphome.core import EsphomeError def test_versions_before_3_are_rejected() -> None: @@ -21,3 +26,27 @@ def test_supported_versions_pass() -> None: value = _arduino_check_versions({CONF_VERSION: "recommended"}) assert value[CONF_VERSION] == "3.1.2" assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION] + + +def test_framework_package_version() -> None: + assert _framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" + assert _framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" + # A future major bump needs its own encoding, not a doomed registry lookup + with pytest.raises(EsphomeError, match="not supported yet"): + _framework_package_version(cv.Version(4, 0, 0)) + # Cores before 3.x cannot build ESPHome (C++20) and are rejected + with pytest.raises(EsphomeError, match="requires core 3"): + _framework_package_version(cv.Version(2, 7, 4)) + + +def test_format_framework_arduino_version_pins_all_series() -> None: + """The PlatformIO source formatter across every encoding era.""" + + assert _format_framework_arduino_version(cv.Version(3, 1, 2)) == "~3.30102.0" + # Pre-3 cores are rejected with the version line anchored + with pytest.raises(cv.Invalid, match="requires core 3"): + _format_framework_arduino_version(cv.Version(2, 7, 4)) + # Anchored to the framework version line, not a bare EsphomeError + with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: + _format_framework_arduino_version(cv.Version(4, 0, 0)) + assert excinfo.value.path == ["version"] diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index 951217c8a10..d54c2c35334 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -67,21 +67,24 @@ def test_valid_config_passes() -> None: def test_platformio_toolchain_skips_checks() -> None: - # 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native - # toolchain's own 3.1.1 floor; the bogus board only the native path checks + # 3.0.2 is pio-legal (>= the global 3.0.0 floor) but has no native build; + # the bogus board only the native path checks CORE.toolchain = Toolchain.PLATFORMIO config = _config(board="not_a_board", version="3.0.2") assert _validate_native_toolchain(config) is config -def test_version_below_floor_rejected() -> None: - # 3.1.0 has no registry package, so the native floor is 3.1.1 - with pytest.raises(cv.Invalid, match="3.1.1 or newer"): - _validate_native_toolchain(_config(version="3.1.0")) +def test_version_without_build_rejected() -> None: + """Only the core versions built in esphome-libs/arduino-esp8266 work.""" + with pytest.raises( + cv.Invalid, match=r"3\.1\.1.*available: 3\.1\.2.*platformio" + ) as excinfo: + _validate_native_toolchain(_config(version="3.1.1")) + assert excinfo.value.path == [CONF_FRAMEWORK, CONF_VERSION] -def test_version_at_floor_accepted() -> None: - _validate_native_toolchain(_config(version="3.1.1")) +def test_built_version_accepted() -> None: + _validate_native_toolchain(_config(version="3.1.2")) def test_custom_platform_version_warns_and_is_dropped( diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 6f43f0bf40c..a4193b9a748 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from esphome.arduino8266 import framework +from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION import esphome.config_validation as cv from esphome.core import CORE, EsphomeError @@ -18,32 +19,6 @@ def _build_path(tmp_path: Path) -> None: CORE.build_path = tmp_path -def test_framework_package_version() -> None: - assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" - assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" - # A future major bump needs its own encoding, not a doomed registry lookup - with pytest.raises(EsphomeError, match="not supported yet"): - framework.framework_package_version(cv.Version(4, 0, 0)) - # Cores before 3.x cannot build ESPHome (C++20) and are rejected - with pytest.raises(EsphomeError, match="requires core 3"): - framework.framework_package_version(cv.Version(2, 7, 4)) - - -def test_format_framework_arduino_version_pins_all_series() -> None: - """The esp8266 component's PIO source formatter across every encoding - era, including the 4.x rejection it now shares with the installer.""" - from esphome.components.esp8266 import _format_framework_arduino_version as fmt - - assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0" - # Pre-3 cores are rejected with the version line anchored - with pytest.raises(cv.Invalid, match="requires core 3"): - fmt(cv.Version(2, 7, 4)) - # Anchored to the framework version line, not a bare EsphomeError - with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: - fmt(cv.Version(4, 0, 0)) - assert excinfo.value.path == ["version"] - - def test_tools_path_default_and_prefix(tmp_path: Path) -> None: with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}): assert framework.get_arduino8266_tools_path() == tmp_path.resolve() @@ -54,6 +29,27 @@ def test_tools_path_default_and_prefix(tmp_path: Path) -> None: assert path != Path.cwd() +def _recommended() -> framework.FrameworkRelease: + return framework.FRAMEWORK_RELEASES[RECOMMENDED_ARDUINO_FRAMEWORK_VERSION] + + +def test_framework_releases_are_pinned() -> None: + """A release must not be pinned without its checksum, and the recommended + core must have one.""" + assert RECOMMENDED_ARDUINO_FRAMEWORK_VERSION in framework.FRAMEWORK_RELEASES + for version, release in framework.FRAMEWORK_RELEASES.items(): + assert release.tag.startswith(f"{version}-esphome.") + assert len(release.sha256) == 64 + assert release.size > 0 + + +def test_framework_download() -> None: + download = framework.FrameworkRelease("1.2.3-esphome.4", "a" * 64, 5).download() + releases = "https://github.com/esphome-libs/arduino-esp8266/releases/" + archive = "arduino-esp8266-1.2.3-esphome.4.tar.gz" + assert download == (f"{releases}download/1.2.3-esphome.4/{archive}", "a" * 64, 5) + + def test_toolchain_builds_are_pinned() -> None: """A release must not be pinned without its checksums.""" for sha256, size in framework.TOOLCHAIN_BUILDS.values(): @@ -86,14 +82,15 @@ def test_toolchain_download_unsupported_system() -> None: def _fake_framework(tmp_path: Path) -> None: """The layout install_package expects of an installed framework.""" for sub in ("cores/esp8266", "tools/sdk", "libraries"): - (tmp_path / "frameworks" / "3.30102.0" / sub).mkdir(parents=True) - (tmp_path / "frameworks" / "3.30102.0" / ".esphome_extracted").touch() + (tmp_path / "frameworks" / _recommended().tag / sub).mkdir(parents=True) + (tmp_path / "frameworks" / _recommended().tag / ".esphome_extracted").touch() def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None: """With a mirror override an unsupported host can bring its own toolchain.""" with ( patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", ["http://f"]), patch.object(framework, "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", ["http://m"]), patch.object(framework, "install_package") as mock_install, patch.object(framework, "prefetch_packages") as mock_prefetch, @@ -101,7 +98,10 @@ def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None ): framework.check_and_install(cv.Version(3, 1, 2)) assert mock_prefetch.call_args.args[2] == {} - assert mock_install.call_args_list[1].kwargs["resolve"] is None + assert [call.kwargs["resolve"] for call in mock_install.call_args_list] == [ + None, + None, + ] def test_check_and_install_installed_toolchain_on_unsupported_host( @@ -147,7 +147,7 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), ): paths = framework.check_and_install(cv.Version(3, 1, 2)) - assert paths.framework == tmp_path / "frameworks" / "3.30102.0" + assert paths.framework == tmp_path / "frameworks" / _recommended().tag assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION assert paths.ninja == tmp_path / "ninja" assert mock_install.call_count == 2 @@ -156,14 +156,14 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: fw_call, tc_call = mock_install.call_args_list assert fw_call.args == ( framework.FRAMEWORK_PACKAGE, - "3.30102.0", - tmp_path / "frameworks" / "3.30102.0", + _recommended().tag, + tmp_path / "frameworks" / _recommended().tag, framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, tmp_path / "downloads", ) assert fw_call.kwargs == { "expect": ("cores/esp8266", "tools/sdk", "libraries"), - "resolve": None, + "resolve": _recommended().download, } assert tc_call.args == ( framework.TOOLCHAIN_PACKAGE, @@ -181,8 +181,8 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: [ ( framework.FRAMEWORK_PACKAGE, - "3.30102.0", - tmp_path / "frameworks" / "3.30102.0", + _recommended().tag, + tmp_path / "frameworks" / _recommended().tag, framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, ), ( @@ -193,7 +193,10 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: ), ], tmp_path / "downloads", - {framework.TOOLCHAIN_PACKAGE: framework.toolchain_download}, + { + framework.FRAMEWORK_PACKAGE: _recommended().download, + framework.TOOLCHAIN_PACKAGE: framework.toolchain_download, + }, ) @@ -204,10 +207,10 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: assert env["CCACHE_DIR"] == "x" -def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: - """Calling the installer below the floor fails before any download.""" - with pytest.raises(EsphomeError, match=">= 3.1.1"): - framework.check_and_install(cv.Version(3, 0, 2)) +def test_check_and_install_rejects_unbuilt_core(tmp_path: Path) -> None: + """A core version without a build fails before any download.""" + with pytest.raises(EsphomeError, match=r"3\.1\.1.*available: 3\.1\.2"): + framework.check_and_install(cv.Version(3, 1, 1)) def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: From b22f2e6b4c50e9cb5a78c41aa8d4c7ff56e98ce2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:51:12 +0200 Subject: [PATCH 712/759] [core] Leave the precompiled header off on Windows for a GCC with bug 14940 (#19875) --- esphome/build_gen/arduino8266.py | 8 +- esphome/build_gen/espidf.py | 26 ++++++- esphome/build_gen/host.py | 1 + esphome/build_helpers/ninja_gen.py | 12 +-- esphome/build_helpers/pch.py | 74 ++++++++++++++++++- esphome/components/nrf52/__init__.py | 14 +++- esphome/espidf/toolchain.py | 7 ++ esphome/platformio/pch.py.script | 32 +++++++- esphome/platformio/toolchain.py | 9 ++- .../unit_tests/build_gen/test_arduino8266.py | 13 ++++ tests/unit_tests/build_gen/test_espidf.py | 48 ++++++++++++ tests/unit_tests/build_helpers/test_pch.py | 67 +++++++++++++++++ tests/unit_tests/components/nrf52/test_pch.py | 31 +++++--- tests/unit_tests/conftest.py | 15 +++- tests/unit_tests/test_espidf_toolchain.py | 8 ++ .../unit_tests/test_platformio_pch_script.py | 55 +++++++++++++- tests/unit_tests/test_platformio_toolchain.py | 25 +++++++ 17 files changed, 410 insertions(+), 35 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index c66292a8363..fd198b2e0c7 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1053,12 +1053,9 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] ) + cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ - *tool_lines( - (toolchain_tool(paths.toolchain, "gcc"),), - (toolchain_tool(paths.toolchain, "g++"),), - ccache, - ), + *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), *compile_rule_lines(), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), @@ -1146,6 +1143,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: flag_sets.cxxflags, src_other, (str(paths.framework), str(paths.toolchain)), + cxx, ) src_objs = compile_edges( lines, diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 766c945467f..9f48013a4e8 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,6 +3,7 @@ import json import logging from pathlib import Path +import textwrap from esphome.build_helpers import pch from esphome.components.esp32 import ( @@ -289,6 +290,8 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC # Where CMake puts the .gch of the src component; ccache reads the checksum # next to it in place of the .gch _PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum" +# Where the Windows gate records its choice +_PCH_CHOICE_VAR = "ESPHOME_PCH" def _pch_cmake_block() -> str: @@ -300,11 +303,21 @@ def _pch_cmake_block() -> str: f' "$<$:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"' for header in pch.PCH_DEFAULT_HEADERS ) - return f""" -# ESPHome precompiled header -target_precompile_headers(${{COMPONENT_LIB}} PRIVATE + block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE {headers} -) +)""" + if not pch.pch_needs_gcc_check(): + return f"\n# ESPHome precompiled header\n{block}\n" + # Before the first configure only CMake knows the compiler version + return f""" +# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC})) + message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it") + set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE) +else() + set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE) +{textwrap.indent(block, " ")} +endif() """ @@ -315,8 +328,13 @@ def _read_if_exists(path: Path) -> str: def write_pch_checksum() -> None: """Write the checksum ccache uses in place of the .gch: the core headers, the framework version, the sdkconfig and the managed component versions.""" + from esphome.espidf.toolchain import get_cmake_cache_value + if not pch.pch_enabled(): return + # The gate's choice, cached by configure + if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON": + return pch.log_pch_in_use() checksum = pch.pch_checksum( CORE.relative_src_path(), diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index 4f7c556a488..f1e53e1ccb2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -274,6 +274,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: [*compilers.cxx[1:], *cxxflags], (), (compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (), + compilers.cxx, ) src_objs = compile_edges( lines, diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 40cc8e7400a..8e72b36b3fd 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -24,8 +24,8 @@ from esphome.build_helpers.pch import ( PCH_SUM_NAME, log_pch_in_use, pch_consumer_flags, - pch_enabled, pch_identity, + pch_usable, write_pch_headers, ) from esphome.framework_helpers import strip_win_long_path_prefix @@ -125,15 +125,17 @@ def pch_edges( cxxflags: Sequence[str], src_flags: Sequence[str], identity: Sequence[str], + cxx: Sequence[Path | str], ) -> tuple[str, str] | None: """Emit the precompiled header for the C++ src edges. ``headers`` are folded into one prefix header, ``src_flags`` are the - flags every src edge carries and ``identity`` names what else the - compile depends on, the compiler included. Returns the ``cxx_override`` - for ``compile_edges``, or None without a pch. + flags every src edge carries, ``identity`` names what else the compile + depends on, the compiler included, and ``cxx`` is what the host rule + asks. Returns the ``cxx_override`` for ``compile_edges``, or None + without a pch. """ - if not pch_enabled(): + if not pch_usable(cxx): return None if any( tok == "-include" or tok.startswith("--include") or is_joined_include(tok) diff --git a/esphome/build_helpers/pch.py b/esphome/build_helpers/pch.py index ec0ff416e50..c2e04c9c651 100644 --- a/esphome/build_helpers/pch.py +++ b/esphome/build_helpers/pch.py @@ -2,13 +2,15 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence import hashlib import logging import os from pathlib import Path import posixpath import re +import subprocess +import sys from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env from esphome.const import PLATFORM_NRF52 @@ -63,6 +65,76 @@ def pch_enabled() -> bool: return parse_enable_env("ESPHOME_PCH_ENABLE") is not False +def pch_forced() -> bool: + """``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no.""" + return parse_enable_env("ESPHOME_PCH_ENABLE") is True + + +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed; +# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table +PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) +# The same rule for CMake, which alone knows the version before configure +PCH_WINDOWS_CMAKE_OLD_GCC = ( + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)" +) + + +def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool: + """Whether a GCC of this version loads a .gch on Windows.""" + if not version: + return False + fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT) + return tuple(version[:2]) >= fixed + + +# GCC ends the first --version line with its version; clang names itself +_VERSION_RE = re.compile(r"\d+(?:\.\d+)+") + + +def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None: + """The GCC version from ``--version``: () when it cannot be read, None + for a compiler that is not GCC.""" + try: + result = subprocess.run( + [*cxx, "--version"], capture_output=True, text=True, check=False + ) + except OSError as err: + _LOGGER.debug("Cannot run %s: %s", cxx[0], err) + return () + banner = result.stdout.partition("\n")[0] + if "clang" in banner.lower(): + return None + found = _VERSION_RE.findall(banner) + return tuple(int(part) for part in found[-1].split(".")) if found else () + + +def pch_needs_gcc_check() -> bool: + """Windows host with the knob unset: the compiler version decides.""" + return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None + + +def pch_usable(cxx: Sequence[Path | str]) -> bool: + """The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule.""" + if not pch_enabled(): + return False + if not pch_needs_gcc_check(): + return True + version = gcc_version(cxx) + if version is None or gcc_relocates_pch_on_windows(version): + return True + _LOGGER.info( + "GCC %s cannot load a precompiled header on Windows (GCC bug 14940, " + "fixed in 14.4, 15.3 and 16); compiling without it " + "(set ESPHOME_PCH_ENABLE=1 to force)", + ".".join(map(str, version)) or "of unknown version", + ) + return False + + def pch_consumer_flags() -> list[str]: """Flags a C++ src compile loads the pch with. The -include stays relative: an absolute path would enter the ccache key.""" diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 488984ff70d..8e91cff0497 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -75,6 +75,7 @@ from .framework import ( get_build_env, get_build_paths, setup_platformio_python_env, + toolchain_tool, ) # force import gpio to register pin schema @@ -843,7 +844,11 @@ def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: write_file_if_changed(app_dir / _PCH_SUM_PATH, checksum + "\n") -def _generate_cmake_lists() -> bool: +def _pch_usable() -> bool: + return pch.pch_usable((toolchain_tool("g++"),)) + + +def _generate_cmake_lists(pch_on: bool) -> bool: """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() link_flags = get_project_link_flags() @@ -886,7 +891,7 @@ def _generate_cmake_lists() -> bool: ")", ] - if pch.pch_enabled(): + if pch_on: lines += _PCH_CMAKE_LINES if link_flags: @@ -945,7 +950,8 @@ def run_compile(args, config: ConfigType) -> bool: paths = get_build_paths() env = get_build_env() - cmake_lists_changed = _generate_cmake_lists() + pch_on = _pch_usable() + cmake_lists_changed = _generate_cmake_lists(pch_on) board = zephyr_data()[KEY_BOARD] build_dir = CORE.relative_pioenvs_path(CORE.name) @@ -962,7 +968,7 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) - if pch.pch_enabled(): + if pch_on: pch.log_pch_in_use() # Zephyr turns ccache on by itself when it is installed env.update(pch.ccache_pch_env()) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index f8575234f88..3bb1f5b83d5 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -605,6 +605,13 @@ def get_elf_path() -> Path: return build_dir / "firmware.elf" +def get_cmake_cache_value(var_name: str) -> str | None: + """One entry of the configured build's CMake cache, or None when unset.""" + cmake_output = _get_cmake_output(CORE.relative_build_path("build")) + match = re.search(rf"^{var_name}:\w+=(.*)$", cmake_output, re.MULTILINE) + return match.group(1).strip() if match else None + + def get_objdump_path() -> Path: return _get_cmake_tool_path("CMAKE_OBJDUMP") diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 960d5bd7d9e..36751074d96 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -60,6 +60,10 @@ rc = libc.posix_spawn(None, argv[0], None, attr, (ctypes.c_char_p * len(argv))(* libc.strerror.restype = ctypes.c_char_p sys.exit("ESPHome: cannot start " + real + ": " + libc.strerror(rc).decode()) """ +# GCC bug 14940: before these releases the Windows loader maps a .gch only +# at its saved address. First fixed release per major, 16 on always fixed +_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)} +_WINDOWS_GCC_FIXED_DEFAULT = (16, 0) _CXX_SUFFIXES = (".cpp", ".cc", ".cxx") _HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".inc") _INCLUDE_DIR_OPTIONS = ("-I", "-isystem", "-iquote", "-idirafter") @@ -113,7 +117,11 @@ def _src_envs(src_dir: Path) -> list: def _driver_output(cxx: str, option: str) -> str: - result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + """What the driver prints for ``option``, or "" when it cannot run.""" + try: + result = subprocess.run([cxx, option], capture_output=True, text=True) # noqa: PLW1510 + except OSError: + return "" return result.stdout.strip() @@ -141,6 +149,26 @@ def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): return [f"-B{_CC1_DIR}/"] +def _gcc_loads_pch_here(cxx: str) -> bool: + """Whether a .gch loads here; ESPHOME_PCH_ENABLE=1, as esphome + normalizes it, insists.""" + if sys.platform != "win32" or os.environ.get("ESPHOME_PCH_ENABLE") == "1": + return True + # GCC ends the first --version line with its version; clang names itself + banner = _driver_output(cxx, "--version").partition("\n")[0] + if "clang" in banner.lower(): + return True + found = re.findall(r"\d+(?:\.\d+)+", banner) + version = tuple(int(p) for p in found[-1].split(".")) if found else () + if version and version[:2] >= _WINDOWS_GCC_FIXED.get(version[0], _WINDOWS_GCC_FIXED_DEFAULT): + return True + print( + f"ESPHome: GCC {'.'.join(map(str, version)) or 'of unknown version'} cannot load a " + "precompiled header on Windows (GCC bug 14940); compiling without it" + ) + return False + + def _setup_pch() -> None: if projenv is None: if "nobuild" in COMMAND_LINE_TARGETS: # noqa: F821 @@ -157,6 +185,8 @@ def _setup_pch() -> None: src_envs = _src_envs(src_dir) cxx = src_envs[0].subst("$CXX") + if not _gcc_loads_pch_here(cxx): + return pio_platform = env.PioPlatform() # noqa: F821 extra = _old_gcc_flags(proj_dir, pio_platform.name, cxx) if extra is None: diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 6a906a42d06..0eec532c7c7 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -336,10 +336,17 @@ def run_platformio_cli(*args, **kwargs) -> str | int: env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) if CORE.is_configured: - from esphome.build_helpers.pch import ccache_pch_env, pch_script_enabled + from esphome.build_helpers.pch import ( + ccache_pch_env, + pch_forced, + pch_script_enabled, + ) if pch_script_enabled(): env.update(ccache_pch_env()) + if pch_forced(): + # Normalized for the script, like ESPHOME_CCACHE_ENABLE + env["ESPHOME_PCH_ENABLE"] = "1" # The runner offers the out-of-flash tip but has no configured CORE, so # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same # variable; clear an inherited one so it cannot reach the wrong build. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 8bd304b42be..4a0355fd010 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -447,6 +447,19 @@ def test_write_project_pch_disabled( assert " flags = $srcflags" in content +def test_write_project_pch_asks_the_toolchain_compiler_on_windows( + windows_gcc_rule: None, tmp_path: Path +) -> None: + from esphome.build_helpers import pch + + paths = _make_framework(tmp_path) + _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + with patch.object(pch, "gcc_version", return_value=(10, 3, 0)) as asked: + content = _write_ninja(paths) + assert asked.call_args.args[0] == (toolchain_tool(paths.toolchain, "g++"),) + assert "esphome_pch" not in content + + def test_write_project_scanf_float_and_waveform_kept(tmp_path: Path) -> None: paths = _make_framework(tmp_path) CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = True diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index ec8e2f3a364..4a842847962 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -538,6 +538,54 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None assert "target_precompile_headers" not in get_component_cmakelists() +def test_component_cmakelists_pch_gate_on_windows( + windows_gcc_rule: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """The block carries the rule and records its choice; the knob drops + the gate.""" + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + 'if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ' + "(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR " + "(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND " + "CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n" + " message(STATUS " in content + ) + assert ( + ' set(ESPHOME_PCH OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)\nelse()\n' + in content + ) + assert ( + ' set(ESPHOME_PCH ON CACHE BOOL "ESPHome precompiled header in use" FORCE)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n' + in content + ) + assert "endif()" in content + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + content = get_component_cmakelists() + assert "if(CMAKE_CXX_COMPILER_VERSION" not in content + assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content + + +@pytest.mark.parametrize(("choice", "written"), [("OFF", False), ("ON", True)]) +def test_pch_checksum_follows_the_cmake_choice_on_windows( + windows_gcc_rule: None, tmp_path: Path, choice: str, written: bool +) -> None: + from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum + + _make_pch_project(tmp_path) + with ( + patch( + "esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice + ) as asked, + patch.object(CORE, "name", "test"), + ): + write_pch_checksum() + assert asked.call_args.args == ("ESPHOME_PCH",) + assert CORE.relative_build_path(_PCH_SUM_PATH).exists() is written + + @pytest.mark.parametrize( ("file", "content"), [ diff --git a/tests/unit_tests/build_helpers/test_pch.py b/tests/unit_tests/build_helpers/test_pch.py index a03b9c3a11d..1182dc6f8cd 100644 --- a/tests/unit_tests/build_helpers/test_pch.py +++ b/tests/unit_tests/build_helpers/test_pch.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import os from pathlib import Path +import sys from unittest.mock import patch import pytest @@ -35,6 +37,71 @@ def test_pch_enabled(value: str | None, expected: bool) -> None: assert pch.pch_enabled() is expected +@pytest.mark.parametrize( + ("version", "expected"), + [ + ((), False), + ((10, 3), False), + ((12, 2, 1), False), + ((14, 2, 0), False), + ((14, 3), False), + ((14, 4), True), + ((14,), False), + ((15, 2, 0), False), + ((15, 3), True), + ((16, 0), True), + ((17, 1), True), + ], +) +def test_gcc_relocates_pch_on_windows(version: tuple[int, ...], expected: bool) -> None: + assert pch.gcc_relocates_pch_on_windows(version) is expected + + +@pytest.mark.parametrize( + ("banner", "expected"), + [ + ("xtensa-esp32-elf-g++ (crosstool-NG esp-14.2.0_20260121) 14.2.0", (14, 2, 0)), + ("arm-zephyr-eabi-g++ (Zephyr SDK 0.16.8) 12.2.0", (12, 2, 0)), + ("g++.exe (Rev3, Built by MSYS2 project) 14.2.0", (14, 2, 0)), + ("clang version 15.0.0", None), + ("Apple clang version 17.0.0 (clang-1700.0.13.3)", None), + ("something else", ()), + ], +) +def test_gcc_version_reads_the_banner(banner: str, expected: object) -> None: + cxx = (sys.executable, "-c", f"print({banner!r}); print('more')") + assert pch.gcc_version(cxx) == expected + + +def test_gcc_version_of_a_compiler_that_cannot_run() -> None: + assert pch.gcc_version(("/nonexistent/g++",)) == () + + +def test_pch_usable_asks_the_compiler_on_windows_only( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.INFO, logger=pch.__name__) + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "darwin") + with patch.object(pch, "gcc_version", side_effect=AssertionError("off Windows")): + assert pch.pch_usable(("g++",)) + monkeypatch.setattr(pch.sys, "platform", "win32") + with patch.object(pch, "gcc_version", return_value=(14, 2, 0)): + assert not pch.pch_usable(("g++",)) + assert "GCC 14.2.0 cannot load a precompiled header on Windows" in caplog.text + with patch.object(pch, "gcc_version", return_value=(14, 4, 0)): + assert pch.pch_usable(("g++",)) + # The bug is GCC's; another compiler is not held to its table + with patch.object(pch, "gcc_version", return_value=None): + assert pch.pch_usable(("clang++",)) + # The knob overrides the rule both ways + with patch.object(pch, "gcc_version", side_effect=AssertionError("forced")): + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + assert pch.pch_usable(("g++",)) + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + assert not pch.pch_usable(("g++",)) + + def test_ccache_pch_env_enabled() -> None: with patch.dict(os.environ, {}, clear=True): env = pch.ccache_pch_env() diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py index 0052df07720..5af55586c33 100644 --- a/tests/unit_tests/components/nrf52/test_pch.py +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -13,7 +13,7 @@ from esphome.core import CORE, EsphomeError SUM = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" -def _generate_cmake(tmp_path: Path) -> str: +def _generate_cmake(tmp_path: Path, pch_on: bool = True) -> str: CORE.config_path = tmp_path / "test.yaml" CORE.build_path = tmp_path / "build" CORE.name = "livingroom" @@ -25,7 +25,7 @@ def _generate_cmake(tmp_path: Path) -> str: patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]), patch.object(nrf52, "get_project_link_flags", return_value=[]), ): - nrf52._generate_cmake_lists() + nrf52._generate_cmake_lists(pch_on) return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() @@ -43,15 +43,27 @@ def test_cmake_lists_precompile_the_core_headers(tmp_path: Path) -> None: assert "if(NOT esphome_pch_headers)\n message(FATAL_ERROR" in text -def test_cmake_lists_pch_block_disabled( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") - text = _generate_cmake(tmp_path) +def test_cmake_lists_pch_block_disabled(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path, pch_on=False) assert "precompile" not in text assert "zephyr_interface" not in text +@pytest.mark.parametrize(("version", "on"), [((12, 2, 0), False), ((14, 4, 0), True)]) +def test_the_zephyr_compiler_decides_on_windows( + windows_gcc_rule: None, version: tuple[int, ...], on: bool +) -> None: + from esphome.build_helpers import pch + + # platformdirs would pick its Windows backend from the patched sys.platform + with ( + patch.object(nrf52, "toolchain_tool", lambda name: Path(f"/sdk/{name}.exe")), + patch.object(pch, "gcc_version", return_value=version) as asked, + ): + assert nrf52._pch_usable() is on + assert asked.call_args.args[0] == (Path("/sdk/g++.exe"),) + + def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: """Write the checksum for a build dir whose app image sits in ``app``.""" CORE.build_path = tmp_path @@ -114,9 +126,10 @@ def run_cmd(tmp_path: Path) -> Mock: def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: - # clear=True also drops ambient CCACHE_* and ESPHOME_PCH_* overrides + # clear=True also drops ambient CCACHE_* overrides; the header is on + # explicitly since Windows hosts start with it off with ( - patch.dict("os.environ", {}, clear=True), + patch.dict("os.environ", {"ESPHOME_PCH_ENABLE": "1"}, clear=True), pytest.raises(EsphomeError, match="nRF52 native build failed"), ): nrf52.run_compile(None, {}) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 0353c8afc83..ff5564e8150 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -178,6 +178,15 @@ def held_lock() -> Callable[..., Callable[..., None]]: @pytest.fixture(autouse=True) def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None: - """The precompiled header knobs change what the generators emit; a value - exported in the developer's or CI's shell must not reach the tests.""" - monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False) + """The knob changes what the generators emit: no ambient value, and on, + so the Windows runner asks no compiler.""" + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1") + + +@pytest.fixture +def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None: + """Windows host, knob unset: the compiler version decides.""" + from esphome.build_helpers import pch + + monkeypatch.delenv("ESPHOME_PCH_ENABLE") + monkeypatch.setattr(pch.sys, "platform", "win32") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2d655636632..3e1e88d66da 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -725,3 +725,11 @@ def test_get_core_framework_version_from_core_data(): CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}} assert toolchain._get_core_framework_version() == "5.5.4" + + +def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> None: + _setup_build(setup_core) + output = "CMAKE_OBJDUMP:FILEPATH=/tools/objdump\nESPHOME_PCH:BOOL=OFF\n" + with patch.object(toolchain, "_get_cmake_output", return_value=output): + assert toolchain.get_cmake_cache_value("ESPHOME_PCH") == "OFF" + assert toolchain.get_cmake_cache_value("ESPHOME_MISSING") is None diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index e7ea1a34201..af9ec4a8377 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -81,7 +81,9 @@ def _fake_cxx(tmp_path: Path, fail: bool = False) -> Path: 'case "$1" in -print-prog-name=*) n=${1#*=};' ' p="$(dirname "$0")/../libexec/gcc/arm-none-eabi/10.3.1/$n";' ' [ -x "$p" ] && echo "$p" || echo "$n"; exit 0;;' - ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;; esac\n' + ' -dumpversion) echo "${FAKE_GCC_VERSION:-10.3.1}"; exit 0;;' + ' --version) echo "${FAKE_GCC_BANNER:-fake-g++ (test) ${FAKE_GCC_VERSION:-10.3.1}}";' + " exit 0;; esac\n" 'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n' ) if fail: @@ -239,7 +241,8 @@ def test_pch_script_gcc10_skipped_elsewhere( ("linux", "aarch64", _LibreTinyPlatform, "10.3.1"), # From GCC 12 the .gch loads at any address, so the rule retires itself ("darwin", "arm64", _LibreTinyPlatform, "12.2.0"), - ("win32", "AMD64", _LibreTinyPlatform, "14.2.0"), + # Windows needs the fix for GCC bug 14940 as well + ("win32", "AMD64", _LibreTinyPlatform, "14.4.0"), ], ) def test_pch_script_no_wrapper_where_the_gch_loads( @@ -272,6 +275,54 @@ def test_pch_script_names_match_the_python_side(tmp_path: Path) -> None: assert namespace["_GUARD_TEXT"] == pch.PCH_GUARD_TEXT assert namespace["_INCLUDE_RE"].pattern == pch._INCLUDE_RE.pattern assert namespace["_CC1_DIR"] == pch.PCH_CC1_DIR + assert namespace["_WINDOWS_GCC_FIXED"] == pch.PCH_WINDOWS_GCC_FIXED + assert namespace["_WINDOWS_GCC_FIXED_DEFAULT"] == pch.PCH_WINDOWS_GCC_FIXED_DEFAULT + + +@pytest.mark.parametrize( + ("version", "env_vars", "on"), + [ + ("14.2.0", {}, False), + ("14.4.0", {}, True), + ("14.2.0", {"ESPHOME_PCH_ENABLE": "1"}, True), + ], +) +def test_pch_script_asks_the_compiler_on_windows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + version: str, + env_vars: dict[str, str], + on: bool, +) -> None: + """The Python rule again, plus the normalized knob.""" + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_VERSION": version, **env_vars} + ) + assert (scons_env.prepended == pch.pch_consumer_flags()) is on + assert (tmp_path / "dev" / "esphome_pch.h.gch").is_file() is on + out = capsys.readouterr().out + assert ("cannot load a precompiled header on Windows" in out) is not on + + +def test_pch_script_spares_another_compiler_the_gcc_rule( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script( + tmp_path, env_vars={"FAKE_GCC_BANNER": "clang version 15.0.0"} + ) + assert scons_env.prepended == pch.pch_consumer_flags() + + +def test_pch_script_leaves_the_header_off_when_the_compiler_cannot_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + scons_env = _run_script(tmp_path, cxx=tmp_path / "missing-g++") + assert scons_env.prepended == [] + assert "GCC of unknown version cannot load" in capsys.readouterr().out def test_pch_script_compile_failure_stops_the_build(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index d218f3201f5..60a5d1d4235 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -354,6 +354,31 @@ def test_run_platformio_cli_exports_the_pch_ccache_settings( assert env.get("CCACHE_PCH_EXTSUM") == expected +@pytest.mark.parametrize( + ("value", "expected"), [("yes", "1"), ("0", "0"), (None, None)] +) +def test_run_platformio_cli_normalizes_a_forced_pch_for_the_script( + setup_core: Path, + mock_run_external_process: Mock, + value: str | None, + expected: str | None, +) -> None: + """The script cannot import the knob parser, so it only reads a ``1``.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + env_vars = {} if value is None else {"ESPHOME_PCH_ENABLE": value} + with patch.dict(os.environ, env_vars, clear=True): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get("ESPHOME_PCH_ENABLE") == expected + + @pytest.mark.parametrize( ("platform", "framework", "expected"), [ From 55a7340d0672b74710754f632023ab76a71dc580 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 18:52:28 +0200 Subject: [PATCH 713/759] [esp8266] Make the native arduino toolchain the default (#19886) --- docker/test_configs/esp8266-arduino.yaml | 2 ++ esphome/__main__.py | 3 +- esphome/components/esp8266/__init__.py | 14 +++++++- .../esp8266/test_toolchain_validation.py | 34 +++++++++++++++++-- tests/unit_tests/test_vscode.py | 4 +-- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml index 80b52260e4d..5a80fd5b1f1 100644 --- a/docker/test_configs/esp8266-arduino.yaml +++ b/docker/test_configs/esp8266-arduino.yaml @@ -3,5 +3,7 @@ esphome: esp8266: board: d1_mini + # The PlatformIO path stays covered whatever the default is + toolchain: platformio logger: diff --git a/esphome/__main__.py b/esphome/__main__.py index e67bc42764a..01ca8ca5d96 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2367,7 +2367,8 @@ def parse_args(argv): metavar="{" + ",".join(t.value for t in Toolchain) + "}", help=( "Select toolchain for compiling. Overrides '.toolchain' in YAML. " - f"Default: {Toolchain.PLATFORMIO.value}." + "Default: the platform's native toolchain where it has one, else " + f"{Toolchain.PLATFORMIO.value}." ), ) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 0e5303e8df1..e4d8dfc4928 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -111,7 +111,18 @@ def set_core_data(config: ConfigType) -> ConfigType: _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO) _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) -_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO) +_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO) + + +def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP8266 is deprecated and will be " + "removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the " + "default." + ) + return config def _validate_native_toolchain(config: ConfigType) -> ConfigType: @@ -319,6 +330,7 @@ CONFIG_SCHEMA = cv.All( } ), _resolve_toolchain, + _warn_platformio_toolchain, _validate_native_toolchain, set_core_data, ) diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index d54c2c35334..e7f443c2d23 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -14,6 +14,7 @@ from esphome.components.esp8266 import ( ARDUINO_FRAMEWORK_SCHEMA, _resolve_toolchain, _validate_native_toolchain, + _warn_platformio_toolchain, ) import esphome.config_validation as cv from esphome.const import ( @@ -125,10 +126,22 @@ def test_yaml_toolchain_key_resolves() -> None: assert CORE.using_toolchain_arduino -def test_yaml_toolchain_key_defaults_to_platformio() -> None: +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + (None, Toolchain.ARDUINO), + # An explicit `toolchain:` still wins over the default + (Toolchain.PLATFORMIO, Toolchain.PLATFORMIO), + (Toolchain.ARDUINO, Toolchain.ARDUINO), + ], +) +def test_default_toolchain_is_arduino( + config_toolchain: Toolchain | None, expected: Toolchain +) -> None: CORE.toolchain = None - _resolve_toolchain({}) - assert CORE.toolchain == Toolchain.PLATFORMIO + config = {} if config_toolchain is None else {CONF_TOOLCHAIN: config_toolchain} + _resolve_toolchain(config) + assert CORE.toolchain == expected def test_decode_pc_native_missing_tools_warns_once( @@ -198,3 +211,18 @@ def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None: CORE.build_path = tmp_path esp8266.copy_files() assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + ("toolchain", "warns"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ARDUINO, False)], +) +def test_platformio_toolchain_deprecation_warning( + toolchain: Toolchain, warns: bool, caplog: pytest.LogCaptureFixture +) -> None: + CORE.toolchain = toolchain + config = _config() + assert _warn_platformio_toolchain(config) is config + assert ( + "deprecated and will be removed in ESPHome 2027.4.0" in caplog.text + ) is warns diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 9b7d1e95040..7d401eaa653 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -58,7 +58,7 @@ esp8266: board: !secret my_secret_board """), # read_file secrets.yaml - _file_response("""my_secret_board: esp1f"""), + _file_response("""my_secret_board: d1_mini"""), ] ) @@ -83,7 +83,7 @@ esp8266: broad: !secret my_secret_board # typo here """), # read_file secrets.yaml - _file_response("""my_secret_board: esp1f"""), + _file_response("""my_secret_board: d1_mini"""), ] ) From 1a9f43909a42e19d958cda0bbb87bb47e9a560f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:53:59 +0200 Subject: [PATCH 714/759] [mopeka_std_check] Use the sub_* helpers for optional child entities (#19897) --- esphome/components/mopeka_std_check/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index d5c5d4135d6..b3e55766d8a 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -128,15 +128,8 @@ async def to_code(config: ConfigType) -> None: if CONF_PROPANE_BUTANE_MIX in config: cg.add(var.set_propane_butane_mix(config[CONF_PROPANE_BUTANE_MIX])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_LEVEL]) - cg.add(var.set_level(sens)) - if CONF_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_DISTANCE]) - cg.add(var.set_distance(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_LEVEL, var.set_level) + await sensors(CONF_DISTANCE, var.set_distance) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) From 1b86959ee0e8dac8f791061299b6d844e4d5872e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:54:11 +0200 Subject: [PATCH 715/759] [inkbird_ibsth1_mini] Use the sub_* helpers for optional child entities (#19896) --- .../components/inkbird_ibsth1_mini/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index 84a207020ea..f6524021c08 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -71,15 +71,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_EXTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE]) - cg.add(var.set_external_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) From 4618c73b0539885307badd14f8daf6a89bf77618 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:54:33 +0200 Subject: [PATCH 716/759] [ccs811] Use the sub_* helpers for optional child entities (#19895) --- esphome/components/ccs811/sensor.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index d134d2cf21f..95e51c825a8 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_co2(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) - - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(var.set_version(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, var.set_version) if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) From b6cd92e915a56d4e7c0aa6a82bd300b119fd2ab1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:54:52 +0200 Subject: [PATCH 717/759] [hmc5883l] Use the sub_* helpers for optional child entities (#19893) --- esphome/components/hmc5883l/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index a2e1f8054ad..229d6c8b623 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -136,15 +136,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(auto_data_rate(config))) cg.add(var.set_range(config[CONF_RANGE])) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) From a0f933ea604e4f8d271488b034a3fcea6d7709a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:54:55 +0200 Subject: [PATCH 718/759] [htu21d] Use the sub_* helpers for optional child entities (#19894) --- esphome/components/htu21d/sensor.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 08888c17e5c..d78267964b0 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -67,17 +67,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - - if CONF_HEATER in config: - sens = await sensor.new_sensor(config[CONF_HEATER]) - cg.add(var.set_heater(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_HEATER, var.set_heater) cg.add(var.set_sensor_model(config[CONF_MODEL])) From b8ba8aa1a80d12788d27616d826e4dcc39dccea6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 19:55:22 +0200 Subject: [PATCH 719/759] [espidf] Run cmake and ninja directly instead of through idf.py (#19871) --- .github/workflows/ci.yml | 12 + esphome/analyze_memory/toolchain.py | 2 +- esphome/build_helpers/tool_runner.py | 180 ++++++ esphome/build_helpers/tools_cache.py | 4 +- esphome/espidf/clang_tidy.py | 8 +- esphome/espidf/framework.py | 17 +- esphome/espidf/runner.py | 379 ------------- esphome/espidf/size_summary.py | 2 +- esphome/espidf/toolchain.py | 354 ++++++++---- esphome/util.py | 22 +- script/check_idf_py_equivalence.py | 198 +++++++ script/determine-jobs.py | 30 +- tests/script/test_check_idf_py_equivalence.py | 248 +++++++++ tests/script/test_determine_jobs.py | 89 ++- .../build_helpers/test_tool_runner.py | 338 ++++++++++++ .../fixtures/espidf/closing_probe.py | 11 - .../fixtures/espidf/crashing_probe.py | 11 - .../fixtures/espidf/filtering_probe.py | 15 - .../fixtures/espidf/formfeed_probe.py | 12 - .../fixtures/espidf/partial_noise_probe.py | 10 - .../fixtures/espidf/streaming_probe.py | 14 - tests/unit_tests/test_espidf_clang_tidy.py | 38 ++ tests/unit_tests/test_espidf_framework.py | 12 +- tests/unit_tests/test_espidf_runner.py | 315 ----------- tests/unit_tests/test_espidf_toolchain.py | 515 +++++++++++++++--- 25 files changed, 1840 insertions(+), 996 deletions(-) create mode 100644 esphome/build_helpers/tool_runner.py delete mode 100644 esphome/espidf/runner.py create mode 100755 script/check_idf_py_equivalence.py create mode 100644 tests/script/test_check_idf_py_equivalence.py create mode 100644 tests/unit_tests/build_helpers/test_tool_runner.py delete mode 100644 tests/unit_tests/fixtures/espidf/closing_probe.py delete mode 100644 tests/unit_tests/fixtures/espidf/crashing_probe.py delete mode 100644 tests/unit_tests/fixtures/espidf/filtering_probe.py delete mode 100644 tests/unit_tests/fixtures/espidf/formfeed_probe.py delete mode 100644 tests/unit_tests/fixtures/espidf/partial_noise_probe.py delete mode 100644 tests/unit_tests/fixtures/espidf/streaming_probe.py delete mode 100644 tests/unit_tests/test_espidf_runner.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dac899a41a..d9507c59381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1226,8 +1226,20 @@ jobs: if [ -n "$compile_csv" ]; then # Run compilation with grouping and isolation python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # The real idf.py must find nothing to configure or build in a + # tree built above; catches drift on ESP-IDF bumps. + echo "Checking the native ESP-IDF build matches idf.py" + python3 script/check_idf_py_equivalence.py + fi else echo "All components in this batch are validate-only -- skipping compile stage." + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # determine-jobs and this step disagree on what compiles; fail + # rather than let the check run nowhere. + echo "::error::This batch was picked for the idf.py check but compiled nothing" + exit 1 + fi fi - name: Print ccache statistics diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index b64af5ea5b8..7d8195a17b1 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None: """ name = build_path.name for candidate in ( - # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # Native ESP-IDF: the build writes build/.elf, which ESPHome copies # to build/firmware.elf (see espidf.toolchain.create_elf_copy) build_path / "build" / "firmware.elf", # PlatformIO diff --git a/esphome/build_helpers/tool_runner.py b/esphome/build_helpers/tool_runner.py new file mode 100644 index 00000000000..52cd6c937a9 --- /dev/null +++ b/esphome/build_helpers/tool_runner.py @@ -0,0 +1,180 @@ +"""Run a native build tool (cmake, ninja) and relay its output. + +Output is read from a pipe so it can be filtered here: a child that inherits +our stdout writes straight to the file descriptor, past any Python wrapper. +""" + +from __future__ import annotations + +import codecs +from contextlib import suppress +import logging +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from typing import Any, TextIO + +from esphome.util import ANSI_ESCAPE, RedirectText, shlex_quote + +_LOGGER = logging.getLogger(__name__) + +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + +# Same pattern idf.py uses to spot ninja status lines (``is_progression``). +_PROGRESS = re.compile(r"^\[\d+/\d+\]|.*\(\d+ \%\)$") +_READ_SIZE = 65536 + + +def _get_kernel32() -> Any | None: + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class Utf8Console: + """Keep an attached Windows console on UTF-8 while a build tool runs. + + esp_idf_size draws its table with Unicode box characters, and CMake + re-decodes a child's output with the console code page, which garbles the + table on any page but UTF-8. A console already on UTF-8 is left alone so + an overlapping build never records UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32: Any | None) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record first so a switch that fails part way is still undone. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + + +def _fit_terminal(text: str) -> str: + """Elide the middle of ``text`` to fit the terminal, as idf.py does. + + A width of 0 (a pipe, the dashboard) leaves the text whole. + """ + width = shutil.get_terminal_size((0, 0)).columns + if not width: + return text + if width <= 3: + return "." * width + if len(text) >= width: + keep = (width - 3) // 2 + return f"{text[:keep]}...{text[len(text) - keep :]}" + return text + + +class ToolOutput(RedirectText): + """RedirectText that can collapse ninja status lines into one line. + + With ``progress`` each ``[n/m]`` line overwrites the previous one, the + way idf.py shows a build. + """ + + def __init__( + self, out: TextIO, filter_lines: list[str] | None, progress: bool + ) -> None: + super().__init__(out, filter_lines=filter_lines) + self._progress = progress + self._on_progress_line = False + + def _splits_lines(self) -> bool: + return self._progress or super()._splits_lines() + + def _emit_line(self, line: str) -> None: + if self._progress and _PROGRESS.match(line): + if not self._is_filtered(line): + text = _fit_terminal(line.strip("\r\n")) + self._write_color_replace(f"\r{text}\x1b[K") + self._on_progress_line = True + return + self._end_progress_line() + super()._emit_line(line) + + def _end_progress_line(self) -> None: + if self._on_progress_line: + self._on_progress_line = False + self._write_color_replace(os.linesep) + + def drain(self) -> None: + super().drain() + # Called from cleanup, so a broken stream must not hide the exit code. + with suppress(OSError, ValueError): + self._end_progress_line() + self._out.flush() + + +def run_build_tool( + cmd: list[str], + *, + cwd: Path, + env: dict[str, str], + filter_lines: list[str] | None = None, + progress: bool = False, + log_path: Path | None = None, +) -> int: + """Run ``cmd`` and relay stdout and stderr, merged, to our stdout. + + ``log_path`` also gets the full, unfiltered output without color codes, as + idf.py wrote its logs (its hint patterns expect plain text). Returns the + exit code. + """ + _LOGGER.debug("Running: %s", " ".join(shlex_quote(arg) for arg in cmd)) + _LOGGER.debug(" in directory: %s", cwd) + output = ToolOutput(sys.stdout, filter_lines, progress) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + if log_path is not None: + log_path.parent.mkdir(parents=True, exist_ok=True) + with ( + Path(log_path or os.devnull).open("w", encoding="utf-8", newline="") as log, + Utf8Console(_get_kernel32()), + subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + close_fds=False, + ) as proc, + ): + try: + # read1 returns as soon as anything is available, so output + # streams while the tool runs. + while chunk := proc.stdout.read1(_READ_SIZE): + text = decoder.decode(chunk) + log.write(ANSI_ESCAPE.sub("", text)) + output.write(text) + if tail := decoder.decode(b"", final=True): + log.write(ANSI_ESCAPE.sub("", tail)) + output.write(tail) + finally: + output.drain() + return proc.returncode diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index e3d38e7a98e..85036c166fe 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -16,8 +16,8 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: from esphome.helpers import get_str_env if prefix := get_str_env(env_var, "").strip(): - # resolve(): symlinked prefixes otherwise trip idf.py's - # venv-mismatch warning on every build + # resolve(): a symlinked prefix would otherwise record a second + # spelling of the same paths in the build tree return Path(prefix).expanduser().resolve() # appauthor=False keeps the Windows path short (no vendor segment); # deep IDF trees run into MAX_PATH otherwise diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 9a70f1a99aa..3e1a510dee9 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -11,7 +11,7 @@ running codegen on a config, it generates a minimal ESP-IDF CMake project: component, so their public include dirs land on the translation unit; * the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...). -then runs ``idf.py reconfigure`` (configure only, no compile) and reads the +then runs the CMake configure (no compile) and reads the resulting ``build/compile_commands.json``. The IDF version is the esp32 component's recommended version. @@ -367,7 +367,7 @@ def _write_tidy_project( def _generate_compile_commands( work_dir: Path, settings: _Settings, platformio_ini: Path ) -> Path: - """Generate the tidy project and run ``idf.py reconfigure`` (no build). + """Generate the tidy project and run the CMake configure (no build). Two-phase, like a real ESPHome build: a first configure with no builtin requires discovers which components actually register for the target (e.g. @@ -398,7 +398,7 @@ def _generate_compile_commands( # Phase 1: discover the components available for this target. _write_tidy_project(work_dir, [], extra_deps, settings) if toolchain.run_reconfigure() != 0: - raise RuntimeError("idf.py reconfigure (discovery) failed") + raise RuntimeError("ESP-IDF CMake configure (discovery) failed") requires = sorted( set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS @@ -407,7 +407,7 @@ def _generate_compile_commands( # Phase 2: require every available builtin component. _write_tidy_project(work_dir, requires, extra_deps, settings) if toolchain.run_reconfigure() != 0: - raise RuntimeError("idf.py reconfigure failed") + raise RuntimeError("ESP-IDF CMake configure failed") return work_dir / "build" / "compile_commands.json" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 8885da05cca..1a4b17efdb3 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -1211,38 +1211,37 @@ def _ccache_env() -> dict[str, str]: # ESPHOME_CCACHE_ENABLE. idf_knob = parse_enable_env("IDF_CCACHE_ENABLE") if idf_knob is False: - # The raw value (e.g. "disable") is still inherited by idf.py via - # os.environ, where a non-false-constant string reads as truthy; - # export the canonical off spelling instead + # Replace the inherited raw value (e.g. "disable") with the canonical + # off spelling, so every reader of the env sees the same answer return {"IDF_CCACHE_ENABLE": "0"} if idf_knob is True: # Forced on ignores the runnability verdict, but the outcome is # worth saying out loud. Probed directly (not via the resolver, # whose failure message says "compiling without ccache" -- exactly # what forced-on does NOT do): only the truly-missing case means - # idf.py compiles without ccache; a broken binary is still used, - # since idf.py does its own PATH lookup. + # the build compiles without ccache; a broken binary is still used, + # since IDF's CMake does its own PATH lookup. if (ccache := shutil.which("ccache")) is None: _LOGGER.warning( "IDF_CCACHE_ENABLE=1 but no ccache binary is on PATH; " - "idf.py will compile without ccache" + "the build will compile without ccache" ) else: # The probe warns with this message iff the binary fails tool_version_runs( ccache, "IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though " - "it failed to run; idf.py will use it anyway", + "it failed to run; the build will use it anyway", ) elif resolve_ccache_path() is None: # ESP-IDF silently skips ccache without the binary; export the # canonical off spelling so an unparsable inherited value (or a - # probe-rejected ccache idf.py would still find) cannot enable it + # probe-rejected ccache CMake would still find) cannot enable it return {"IDF_CCACHE_ENABLE": "0"} env = ccache_defaults_env(get_idf_tools_path() / "ccache") env.update(ccache_pch_env()) - # Exactly one canonical spelling ever reaches idf.py, whatever the + # Exactly one canonical spelling ever reaches the build, whatever the # accepted input spelling was ("enable", "yes", ...) env["IDF_CCACHE_ENABLE"] = "1" return env diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py deleted file mode 100644 index bf563a4f438..00000000000 --- a/esphome/espidf/runner.py +++ /dev/null @@ -1,379 +0,0 @@ -r"""Subprocess entry point for running ``idf.py`` with stdio wrapping. - -Invoked as ``python runner.py [script args...]``. - -Wraps ``sys.stdout`` and ``sys.stderr`` with a ``_FilteringTTYStream`` -shim so that: - -1. ``isatty()`` unconditionally returns True. CMake, Ninja, and idf.py's - own progress-bar code all check ``stream.isatty()`` to decide between - TTY-format output (``\\r`` cursor moves, ANSI colors, fancy progress - bars) and a plain fallback. With the wrapper in place they always - emit TTY format, even when our real stdout is a pipe to the parent - process (e.g. running under the Home Assistant dashboard add-on). - Downstream consumers — local terminals and the HA dashboard log - viewer — render the TTY control sequences correctly. - -2. ``FILTER_IDF_LINES`` is applied inside the shim's ``write()`` so - noisy idf.py output is dropped before it leaves this subprocess. - Filtering is skipped when ``-v`` / ``--verbose`` appears in argv so - verbose mode still shows everything. - -ESP-IDF runs under its own Python virtual environment which does not -have the ``esphome`` package installed, so the runner is intentionally -self-contained: no imports from ``esphome`` at all. The line-filtering -wrapper is inlined below rather than imported from -``esphome.util.RedirectText`` for that reason. -""" - -import sys - -# Regex patterns matched against each line of idf.py / CMake / Ninja -# output. Lines that match are dropped before reaching the parent -# process. Patterns are anchored at the start of the line (the shim -# uses ``re.match``). Disabled when the user passes ``-v`` / -# ``--verbose`` to ``esphome compile``. -FILTER_IDF_LINES: list[str] = [ - # idf.py's "how to flash" block at the end of a successful build. - # ESPHome handles flashing itself, so these instructions just clutter - # the output. - r"Project build complete\.", - r" idf\.py ", - r" python -m esptool ", - r"or$", - r"or from the ", - # CMake dumps the full list of IDF component paths on one giant line. - # It's purely informational and bloats the log. - r"-- Component paths:", - # CMake lists every linker script it adds (dozens of lines) and the - # complete flat list of IDF components on one giant line. Neither - # has diagnostic value for end users. - r"-- Adding linker script ", - r"-- Components:", - # IDF component manager notices: emitted on first build (no lock), - # once per stubbed dependency, plus the final "Processing N - # dependencies" enumeration. Patterns allow a leading run of dots - # because the component manager prints progress dots on the same - # line, so a NOTICE often arrives prefixed with ".NOTICE:" or - # "...........NOTICE:". - r"\.*NOTICE: ", - # ``idf.py size`` prefaces its table with a centered banner; the - # per-region table below already makes the structure obvious. - r"\s*Memory Type Usage Summary", - # Prefix match for esp-idf-size's trailing "Note:" paragraph (no - # upstream flag suppresses it). - r"Note: The reported total sizes may be smaller than those in the", - # Drop the blank line rich emits after the note so the build log - # doesn't end with an orphan gap before ESPHome's own status lines. - r"\s*$", - # ESP-IDF shells out to ``git rev-parse`` to embed a commit hash; - # esphome-libs strips ``.git`` from the tarball so those probes fail - # noisily without affecting the build. - r"-- git rev-parse returned ", - r"fatal: not a git repository", - r"Stopping at filesystem boundary", -] - -# Windows code page identifier for UTF-8, as used by ``chcp 65001``. -UTF8_CODEPAGE = 65001 - - -def _get_kernel32(): - """Return the Windows kernel32 module, or None on any other platform.""" - if sys.platform != "win32": - return None - import ctypes - - return ctypes.windll.kernel32 - - -class _Utf8Console: - """Keep an attached Windows console on UTF-8 for the length of the build. - - The build tree runs in UTF-8 mode, so esp_idf_size draws its table with - Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``, - and CMake re-decodes the child's output with the console code page before - printing it, which turns the table into mojibake on any code page but - UTF-8. Every process in the build shares this console, so switching it - here covers CMake too. The old code pages go back on exit so the user's - terminal is left as it was. - - A console that is already on UTF-8 is left alone. The code page belongs to - the console, not to this process, so a build that overlaps another one - must not save UTF-8 as the page to go back to. - """ - - def __init__(self, kernel32) -> None: - self._kernel32 = kernel32 - self._codepages: tuple[int, int] | None = None - - def __enter__(self) -> None: - kernel32 = self._kernel32 - if kernel32 is None: - return - old_in = kernel32.GetConsoleCP() - old_out = kernel32.GetConsoleOutputCP() - # Both calls return 0 when no console is attached. - if not old_in or not old_out: - return - if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: - return - # Record the old pages first so a switch that fails part way through - # still gets put back on exit. - self._codepages = (old_in, old_out) - kernel32.SetConsoleCP(UTF8_CODEPAGE) - kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) - - def __exit__(self, *exc_info: object) -> None: - if self._codepages is None: - return - old_in, old_out = self._codepages - self._codepages = None - self._kernel32.SetConsoleCP(old_in) - self._kernel32.SetConsoleOutputCP(old_out) - - -def main() -> int: - # ---- sys.path fix-up --------------------------------------------------- - # - # When Python runs this file as ``python runner.py``, it prepends the - # script's directory — ``/esphome/espidf/`` — to - # ``sys.path[0]``. That directory is part of the esphome package whose - # sibling ``types.py`` (in ``esphome/``) collides with stdlib ``types``. - # Any subsequent import that transitively touches ``types`` (``runpy``, - # ``pathlib``, ``functools``, ``typing``, ...) could resolve the wrong - # module. Drop the entry pre-emptively. ``sys`` is a built-in so - # importing it at module level earlier did not trigger the shadow. - if sys.path and sys.path[0]: - sys.path.pop(0) - # ---- end sys.path fix-up ----------------------------------------------- - - import contextlib - import os - from pathlib import Path - import re - import runpy - - # Patch ``os.get_terminal_size`` to return a fallback size instead - # of raising ``OSError`` when the underlying fd isn't a real - # terminal. - # - # idf.py's ``fit_text_in_terminal`` (in ``idf_py_actions/tools.py``) - # unconditionally calls ``os.get_terminal_size()`` to format ninja - # progress lines. When that raises ``[Errno 25] Inappropriate - # ioctl for device`` on our pipe-backed stdout, idf.py catches the - # exception as ``EnvironmentError`` and silently exits its stdout - # reader coroutine — dropping all ninja build output from that - # point on. Returning a valid value keeps the coroutine alive so - # progress and error lines continue to flow through to the parent - # process. - # - # Honour the ``COLUMNS`` / ``LINES`` env vars if the caller set - # them explicitly. Otherwise fall back to ``(0, 0)``, which - # ``fit_text_in_terminal`` treats as "unknown width, don't - # truncate" (see the ``if not terminal_width: return out`` guard). - # Downstream log viewers (local terminals, the HA dashboard) wrap - # or scroll long lines themselves, so we'd rather emit the full - # file path than have idf.py elide its middle. - _orig_get_terminal_size = os.get_terminal_size - - def _get_terminal_size_fallback(fd: int = 1) -> os.terminal_size: - try: - return _orig_get_terminal_size(fd) - except OSError: - try: - columns = int(os.environ.get("COLUMNS", "0")) - except ValueError: - columns = 0 - try: - lines = int(os.environ.get("LINES", "0")) - except ValueError: - lines = 0 - return os.terminal_size((columns, lines)) - - os.get_terminal_size = _get_terminal_size_fallback # type: ignore[assignment] - - # Strip ANSI escape sequences before comparing a line against the filter - # patterns, so colorized lines still match plain-text patterns. - ansi_escape = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") - - class _FilteringTTYStream: - r"""Minimal stdout/stderr wrapper. - - * ``isatty()`` unconditionally returns True, tricking downstream - code into emitting TTY-format output. - * Input is split with ``str.splitlines(keepends=True)``, which - breaks on more than ``\\n`` and ``\\r``; form feed and a few - other control characters count too. Any piece whose - ANSI-stripped, right-stripped form matches one of - ``filter_lines`` is dropped. - * Only the final piece can still be waiting for more text, so - that one is held until a ``\\n`` or ``\\r`` arrives. A piece - that ended on one of the other breaks goes out as it is. - - Mirrors the matching semantics of ``esphome.util.RedirectText`` - so filter patterns behave identically in both the PlatformIO - and IDF runner paths. - """ - - def __init__(self, stream, filter_lines: list[str] | None) -> None: - self._stream = stream - if filter_lines: - combined = r"|".join(r"(?:" + p + r")" for p in filter_lines) - self._filter_pattern: re.Pattern[str] | None = re.compile(combined) - else: - self._filter_pattern = None - self._line_buffer = "" - - def __getattr__(self, name: str): - # Hide ``buffer`` so consumers that use either - # ``getattr(stream, 'buffer', None)`` or - # ``hasattr(stream, 'buffer')`` see this as a text-only stream - # and skip writing raw bytes (which would bypass the filter). - if name == "buffer": - raise AttributeError(name) - return getattr(self._stream, name) - - def isatty(self) -> bool: - return True - - def flush(self) -> None: - self._stream.flush() - - def _emit(self, line: str) -> None: - if self._filter_pattern is not None: - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - return - self._stream.write(line) - - def drain(self) -> None: - """Write out a held-back line that never got its terminator. - - idf.py and CMake do not always end their last line with a - newline, and a build that dies part way through can stop mid - line. Without this the user is left staring at a build that - ended with no explanation. - """ - if not self._line_buffer: - return - line, self._line_buffer = self._line_buffer, "" - try: - # Add the terminator the line never got, so whatever ESPHome - # prints next does not run onto the same line. - self._emit(line + "\n") - self._stream.flush() - except (OSError, ValueError) as err: - # We are called from cleanup, so raising would replace the - # build's real exit code. Saying so must not raise either: - # under the dashboard our stdout and stderr are the same - # pipe, so whatever broke the write has most likely broken - # the report, and ``sys.__stderr__`` is None on some - # interpreters. Carry the line along; it is usually the - # message saying why the build failed. - if (real_stderr := sys.__stderr__) is not None: - with contextlib.suppress(OSError, ValueError): - print( - f"Could not write out remaining output ({err}): {line}", - file=real_stderr, - ) - - def write(self, data) -> int: - # Text streams normally hand us ``str``; decode in case - # somebody writes bytes directly. - if not isinstance(data, str): - data = data.decode(errors="replace") - - if self._filter_pattern is None: - # Nothing to match against, so no need to wait for a full line. - self._emit(data) - else: - lines = (self._line_buffer + data).splitlines(keepends=True) - # Every piece but the last ends with something - # ``str.splitlines`` treats as a break, so only the last one - # can still be waiting for more text. Hold that one, write - # out the rest. - # - # Some of those breaks are not line endings to us, a form - # feed for one, so a piece can go out without ending in a - # newline. That beats what we did before, which was to stop - # at the first such piece and drop every complete line - # behind it. - if lines and not lines[-1].endswith(("\n", "\r")): - self._line_buffer = lines.pop() - else: - self._line_buffer = "" - for line in lines: - self._emit(line) - - # We tell idf.py it is talking to a terminal, so it sends progress - # bars and cursor moves. Our own stdout is usually a pipe, which is - # block buffered, so without this the build looks frozen until - # 8 KiB of output piles up. - self._stream.flush() - return len(data) - - if len(sys.argv) < 2: - print( - "usage: runner.py [args...]", - file=sys.stderr, - ) - return 2 - - script_path = sys.argv[1] - - # Mirror the platformio runner behaviour: verbose mode disables the - # line filter so all output reaches the user. - is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) - filter_lines = None if is_verbose else FILTER_IDF_LINES or None - - class _FilteredStreams: - """Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims. - - On exit each shim releases a last line that never got its - terminator. The shims made here are drained rather than whatever - ``sys.stdout`` holds by then, which the script is free to replace. - """ - - def __init__(self, filter_lines: list[str] | None) -> None: - self._stdout = _FilteringTTYStream(sys.stdout, filter_lines) - self._stderr = _FilteringTTYStream(sys.stderr, filter_lines) - - def __enter__(self) -> None: - sys.stdout = self._stdout # type: ignore[assignment] - sys.stderr = self._stderr # type: ignore[assignment] - - def __exit__(self, *exc_info: object) -> None: - # Drain stderr from a finally so a surprise from the first one - # cannot strand the second. - try: - self._stdout.drain() - finally: - self._stderr.drain() - - # Shift argv so the target script sees its own path as argv[0] and - # its own arguments starting at argv[1]. runpy.run_path does not - # modify sys.argv itself. - sys.argv = [script_path] + sys.argv[2:] - - # Emulate Python's default behaviour of prepending the script's - # directory to sys.path[0] when running ``python script.py``. - # runpy.run_path does not do this automatically, but idf.py relies - # on it to import its sibling modules (python_version_checker, - # idf_py_actions, ...). - script_dir = str(Path(script_path).resolve().parent) - if script_dir not in sys.path: - sys.path.insert(0, script_dir) - - # If idf.py calls sys.exit(), SystemExit propagates out of run_path - # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the context managers drain - # the streams and put the console back on the way out, and they report - # instead of raising so cleanup cannot bury the real exit code. - with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()): - runpy.run_path(script_path, run_name="__main__") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index d98363dd67e..ffe97ba6186 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -1,6 +1,6 @@ """PlatformIO-format RAM/Flash one-liners after a native ESP-IDF build. -``idf.py size`` (chained onto ``idf.py build`` in +The ninja ``size`` target (run after ``all`` in ``toolchain.run_compile``) prints the per-region table inline as part of the build. This module adds two summary lines underneath, byte-identical to PlatformIO's output: diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 3bb1f5b83d5..d1aeae96031 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -10,6 +10,7 @@ import re import shutil import subprocess +from esphome.build_helpers.tool_runner import run_build_tool from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -189,72 +190,235 @@ def _get_idf_tool(name: str) -> str: return executable -def run_idf_py( - *args, - cwd: Path | None = None, - capture_output: bool = False, - jobs: int | None = None, -) -> int | str: - """Run idf.py with the given arguments.""" - idf_path = _get_idf_path() - if idf_path is None: - raise EsphomeError("ESP-IDF not found") +# Lines dropped from cmake and ninja output unless ``-v`` is given; matched +# with ``re.match`` against the line without ANSI codes or trailing space. +FILTER_IDF_LINES: list[str] = [ + # Full component path and linker script lists, one giant line each. + r"-- Component paths:", + r"-- Adding linker script ", + r"-- Components:", + # Component manager notices; progress dots can precede them. + r"\.*NOTICE: ", + # esp_idf_size banner and trailing note around the size table. + r"\s*Memory Type Usage Summary", + r"Note: The reported total sizes may be smaller than those in the", + r"\s*$", + # esphome-libs tarballs have no .git, so IDF's commit probes fail noisily. + r"-- git rev-parse returned ", + r"fatal: not a git repository", + r"Stopping at filesystem boundary", +] - env = _get_idf_env() - if jobs is not None: - env = {**env, "IDF_PY_BUILD_JOBS": str(jobs)} - python_executable = _get_idf_tool("python") - idf_py = idf_path / "tools" / "idf.py" - # Dispatch idf.py through esphome.espidf.runner, which wraps - # sys.stdout/sys.stderr so ``isatty()`` reports True. This keeps CMake, - # Ninja, and idf.py's own progress-bar code emitting TTY-format output - # (``\r`` cursor moves, ANSI colors, fancy progress bars) even when our - # real stdout is a pipe — e.g. when esphome is running under the Home - # Assistant dashboard add-on. The runner is a plain script (not a - # ``python -m`` module) because IDF's Python venv does not have the - # esphome package installed. - runner_py = Path(__file__).parent / "runner.py" - - cmd = [python_executable, str(runner_py), str(idf_py)] + list(args) - - if cwd is None: - cwd = CORE.build_path - - _LOGGER.debug("Running: %s", " ".join(cmd)) - _LOGGER.debug(" in directory: %s", cwd) - - if capture_output: - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - _LOGGER.error("idf.py failed:\n%s", result.stderr) - return result.stdout - result = subprocess.run( - cmd, - cwd=cwd, - env=env, - check=False, - ) - return result.returncode +# click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. +_CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) +_CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") -def _get_sdkconfig_args() -> list[str]: - """Get cmake -D flags for the sdkconfig file, if it exists.""" +@dataclass(frozen=True, kw_only=True) +class _IdfPyContract: + """How the pinned idf.py drives cmake and ninja (tools/idf_py_actions).""" + + binary_dir_arg: bool # cmake gets -B + ccache_as_bool: bool # CCACHE_ENABLE=True/False instead of 1/0 + size_ng: bool # size target gets ESP_IDF_SIZE_NG=1 + + +_IDF_PY_5 = _IdfPyContract( + binary_dir_arg=False, + ccache_as_bool=False, + size_ng=True, +) +_IDF_PY_6 = _IdfPyContract( + binary_dir_arg=True, + ccache_as_bool=True, + size_ng=False, +) + + +def _idf_py() -> _IdfPyContract: + from esphome.components.esp32 import idf_version + import esphome.config_validation as cv + + return _IDF_PY_6 if idf_version() >= cv.Version(6, 0, 0) else _IDF_PY_5 + + +def _build_dir() -> Path: + """The CMake binary dir; idf.py resolves the project dir the same way.""" + return Path(os.path.realpath(CORE.build_path)) / "build" + + +def _cache_entries() -> dict[str, str]: + """The ``-D`` entries idf.py passes to cmake, in idf.py's order.""" + entries = {} sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") if sdkconfig_path.is_file(): - return ["-D", f"SDKCONFIG={sdkconfig_path}"] - return [] + entries["SDKCONFIG"] = str(sdkconfig_path) + ccache = _get_idf_env().get("IDF_CCACHE_ENABLE", "").strip().lower() in _CLICK_TRUE + entries["CCACHE_ENABLE"] = str(ccache if _idf_py().ccache_as_bool else int(ccache)) + return entries -def run_reconfigure() -> int: - """Run cmake reconfigure only (no build).""" - return run_idf_py(*_get_sdkconfig_args(), "reconfigure") +def _parse_cmakecache(path: Path) -> dict[str, str]: + """Map each ``NAME:TYPE=VALUE`` line of a CMakeCache.txt to NAME: VALUE.""" + result = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if m := _CMAKECACHE_LINE.match(line): + result[m.group(1)] = m.group(3) + return result + + +def _cache_entries_changed() -> bool: + """True when a ``-D`` entry is missing from or differs in CMakeCache.txt. + + idf.py reconfigures on this before every build; ESPHome's own staleness + check does not cover it (for example ccache switched on or off). ESPHome + also compares ``PYTHON``, so a moved IDF prefix reconfigures; idf.py + stopped with an error instead. + """ + cache_path = _build_dir() / "CMakeCache.txt" + if not cache_path.is_file(): + return True + cache = _parse_cmakecache(cache_path) + return any(cache.get(k) != v for k, v in _configure_defines().items()) + + +def _configure_defines() -> dict[str, str]: + """Every ``-D`` idf.py passes to cmake, in its order.""" + return { + "PYTHON_DEPS_CHECKED": "1", + "PYTHON": _get_idf_tool("python"), + "ESP_PLATFORM": "1", + **_cache_entries(), + } + + +def _tool_env() -> dict[str, str]: + """The IDF env plus color, as idf.py 6.x gives every tool. + + Also used on 5.x (which forced CLICOLOR_FORCE for ninja only); color + changes only what is printed, and this way NO_COLOR is respected. + """ + env = dict(_get_idf_env()) + if not env.get("NO_COLOR"): + env.setdefault("CLICOLOR_FORCE", "1") + env.setdefault("FORCE_COLOR", "1") + return env + + +def run_reconfigure(verbose: bool = False) -> int: + """Run the CMake configure, with the arguments idf.py uses.""" + build_dir = _build_dir() + build_dir.mkdir(parents=True, exist_ok=True) + cmd = [_get_idf_tool("cmake"), "-G", "Ninja"] + if _idf_py().binary_dir_arg: + cmd += ["-B", str(build_dir)] + cmd += [f"-D{name}={value}" for name, value in _configure_defines().items()] + cmd.append(str(build_dir.parent)) + log_path = build_dir / "log" / "cmake_output.log" + rc = run_build_tool( + cmd, + cwd=build_dir, + env=_tool_env(), + filter_lines=None if verbose else FILTER_IDF_LINES, + log_path=log_path, + ) + if rc != 0: + # As idf.py does: a partial cache must not look configured. + (build_dir / "CMakeCache.txt").unlink(missing_ok=True) + _LOGGER.error("CMake configure failed with exit code %d", rc) + _print_hints(log_path) + return rc + + +def _size_env() -> dict[str, str]: + """Environment idf.py gives the ``size`` target.""" + env = {"ESP_IDF_SIZE_FORCE_TERMINAL": "1", "SIZE_OUTPUT_FORMAT": "default"} + if _idf_py().size_ng: + env["ESP_IDF_SIZE_NG"] = "1" + return env + + +def _build_jobs(config) -> int | None: + """Ninja's -j: compile_process_limit, else IDF_PY_BUILD_JOBS as idf.py read it.""" + if (limit := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) is not None: + return limit + if not (value := os.environ.get("IDF_PY_BUILD_JOBS")): + return None + try: + jobs = int(value) + except ValueError: + jobs = 0 + if jobs <= 0: + raise EsphomeError("IDF_PY_BUILD_JOBS must be a positive integer") + return jobs + + +def _run_ninja( + target: str, + *, + verbose: bool, + jobs: int | None, + progress: bool = False, + extra_env: dict[str, str] | None = None, +) -> int: + """Build one ninja target, with the flags and env idf.py uses.""" + cmd = [_get_idf_tool("ninja")] + if jobs is not None: + cmd += ["-j", str(jobs)] + if verbose: + cmd.append("-v") + cmd.append(target) + log_path = _build_dir() / "log" / f"ninja_{Path(target).name}_output.log" + rc = run_build_tool( + cmd, + cwd=_build_dir(), + env={**_tool_env(), **(extra_env or {})}, + filter_lines=None if verbose else FILTER_IDF_LINES, + progress=progress and not verbose, + log_path=log_path, + ) + if rc != 0: + _LOGGER.error("ninja %s failed with exit code %d", target, rc) + _print_hints(log_path) + return rc + + +# Runs IDF's own hint matcher (hints.yml plus its hint modules) on a failed +# tool's output, as idf.py did; it only lives in the IDF venv. +_HINTS_SCRIPT = """ +import sys +sys.path.insert(0, sys.argv[1]) +from idf_py_actions.tools import generate_hints +for hint in generate_hints(sys.argv[2]): + print(hint) +""" + + +def _print_hints(log_path: Path) -> None: + """Print ESP-IDF's advice for a failed build; never fails the build itself.""" + try: + result = subprocess.run( + [ + _get_idf_tool("python"), + "-c", + _HINTS_SCRIPT, + str(_get_idf_path() / "tools"), + str(log_path), + ], + env=_get_idf_env(), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (OSError, subprocess.SubprocessError, EsphomeError) as err: + _LOGGER.debug("Could not get ESP-IDF hints: %s", err) + return + if result.returncode != 0: + _LOGGER.debug("Could not get ESP-IDF hints:\n%s", result.stderr) + return + if hints := result.stdout.strip(): + _LOGGER.warning("%s", hints) def _builtin_component_cache_path() -> Path | None: @@ -313,7 +477,9 @@ def save_cached_builtin_components(components: list[str]) -> None: _LOGGER.warning("Could not write component list cache %s: %s", path, err) -def _write_project_and_reconfigure(builtin_components: list[str] | None) -> int: +def _write_project_and_reconfigure( + builtin_components: list[str] | None, verbose: bool = False +) -> int: """Write the full CMakeLists.txt and run the configure for it.""" from esphome.build_gen.espidf import write_project @@ -321,12 +487,12 @@ def _write_project_and_reconfigure(builtin_components: list[str] | None) -> int: write_project(minimal=False, builtin_components=builtin_components) # Explicit reconfigure: ninja only re-runs cmake when CMakeLists.txt # is strictly newer than build.ninja, which fails on coarse-mtime - # filesystems (#18682). Also keeps idf.py from regenerating memory.ld + # filesystems (#18682). Also keeps ninja from regenerating memory.ld # in testing mode. - return run_reconfigure() + return run_reconfigure(verbose) -def _configure_project() -> int: +def _configure_project(verbose: bool = False) -> int: """Configure the project, discovering the built-in components if needed. A cached component list skips the discovery configure. If the configure @@ -337,20 +503,20 @@ def _configure_project() -> int: if (cached := load_cached_builtin_components()) is not None: _LOGGER.info("Using cached ESP-IDF component list") - if _write_project_and_reconfigure(cached) == 0: + if _write_project_and_reconfigure(cached, verbose) == 0: return 0 _LOGGER.warning("Cached component list failed; rediscovering") _builtin_component_cache_path().unlink(missing_ok=True) _LOGGER.info("Discovering available ESP-IDF components...") write_project(minimal=True) - if (rc := run_reconfigure()) != 0: + if (rc := run_reconfigure(verbose)) != 0: _LOGGER.error("Component discovery failed") return rc discovered = get_available_components() if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 - if (rc := _write_project_and_reconfigure(discovered)) != 0: + if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc save_cached_builtin_components(discovered) @@ -489,11 +655,9 @@ def run_compile(config, verbose: bool) -> int: 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ - # Check if we need to do discovery phase - if not need_reconfigure(): - _LOGGER.info("Build configuration is up to date") - else: - if (rc := _configure_project()) != 0: + jobs = _build_jobs(config) + if need_reconfigure(): + if (rc := _configure_project(verbose)) != 0: return rc # cmake does not rewrite CMakeCache.txt when only properties change, # so restamp it or every build repeats discovery. Only after success, @@ -504,45 +668,31 @@ def run_compile(config, verbose: bool) -> int: path = CORE.relative_build_path(name) if path.is_file(): os.utime(path) + elif _cache_entries_changed(): + _LOGGER.info("CMake cache options changed, reconfiguring") + if (rc := run_reconfigure(verbose)) != 0: + return rc + else: + _LOGGER.info("Build configuration is up to date") # In testing mode, generate the linker script first, patch DRAM/IRAM sizes, # then build. memory.ld is regenerated by ninja during the build phase, # so we must patch after it's generated but before linking (same timing # as iram_fix.py.script's AddPreAction hook in the PlatformIO path). if CORE.testing_mode: - memory_ld = CORE.relative_build_path( - "build", "esp-idf", "esp_system", "ld", "memory.ld" - ) - build_dir = CORE.relative_build_path("build") - # Build just the memory.ld target - ninja needs the path relative to build dir - memory_ld_target = os.path.relpath(str(memory_ld), str(build_dir)) - env = _get_idf_env() - ninja_executable = _get_idf_tool("ninja") - result = subprocess.run( - [ninja_executable, "-C", str(build_dir), memory_ld_target], - env=env, - check=False, - ) - if result.returncode != 0: - _LOGGER.error("Failed to generate linker script") - return result.returncode + memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld")) + if (rc := _run_ninja(memory_ld, verbose=verbose, jobs=jobs)) != 0: + return rc _patch_memory_segments() from esphome.build_gen.espidf import write_pch_checksum write_pch_checksum() - # Build - args = [] - - if verbose: - args.append("-v") - - args.extend(_get_sdkconfig_args()) - args.append("build") - args.append("size") - - rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT)) + # idf.py's ``build size``, minus the second ``ninja all`` it runs first. + rc = _run_ninja("all", verbose=verbose, jobs=jobs, progress=True) + if rc == 0: + rc = _run_ninja("size", verbose=verbose, jobs=jobs, extra_env=_size_env()) if rc == 0: size_json = CORE.relative_build_path("build", "esp_idf_size.json") partitions = CORE.relative_build_path("partitions.csv") @@ -553,7 +703,7 @@ def run_compile(config, verbose: bool) -> int: def get_firmware_path() -> Path: """Get the path to the compiled firmware binary. - This is the file idf.py writes directly (named after the project), + This is the file the build writes directly (named after the project), not the copy used for OTA/factory downloads below. """ build_dir = CORE.relative_build_path("build") @@ -584,7 +734,7 @@ def get_ota_firmware_path() -> Path: def get_built_elf_path() -> Path: - """Path to the ELF idf.py writes directly, ``/.elf``. + """Path to the ELF the build writes directly, ``/.elf``. Exists as soon as the build finishes, unlike the ``firmware.elf`` copy that ``create_elf_copy`` makes later. @@ -596,7 +746,7 @@ def get_built_elf_path() -> Path: def get_elf_path() -> Path: """Get the path to the firmware ELF file. - idf.py writes ``/.elf`` directly; this returns the + The build writes ``/.elf`` directly; this returns the ``/firmware.elf`` copy created by ``create_elf_copy`` so the dashboard's "download ELF" link can find it under the PlatformIO-convention name. @@ -723,7 +873,7 @@ def create_ota_bin() -> bool: def create_elf_copy() -> bool: """Copy the ELF binary to firmware.elf for dashboard compatibility. - idf.py writes the ELF at ``/.elf``; the dashboard's + The build writes the ELF at ``/.elf``; the dashboard's "download ELF" link requests the literal filename ``firmware.elf`` (PlatformIO convention), so copy it to that name. """ diff --git a/esphome/util.py b/esphome/util.py index b8ffa048ca1..8aa321d908c 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -179,15 +179,21 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) - def _emit_line(self, line: str) -> None: - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( + def _is_filtered(self, line: str) -> bool: + return ( self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line + and self._filter_pattern.match(ANSI_ESCAPE.sub("", line).rstrip()) + is not None + ) + + def _splits_lines(self) -> bool: + """Whether output is handled line by line rather than passed through.""" + return self._filter_pattern is not None or bool(self._line_callbacks) + + def _emit_line(self, line: str) -> None: + if self._is_filtered(line): return + line_without_end = ANSI_ESCAPE.sub("", line).rstrip() self._write_color_replace(line) # Check for flash size error and provide helpful guidance @@ -233,7 +239,7 @@ class RedirectText: if not isinstance(s, str): s = s.decode() - if self._filter_pattern is not None or self._line_callbacks: + if self._splits_lines(): lines = (self._line_buffer + s).splitlines(True) # Every piece but the last ends with something # ``str.splitlines`` treats as a break, so only the last one can diff --git a/script/check_idf_py_equivalence.py b/script/check_idf_py_equivalence.py new file mode 100755 index 00000000000..fa349e223a4 --- /dev/null +++ b/script/check_idf_py_equivalence.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Check that a native ESP-IDF build tree is what idf.py itself would produce. + +ESPHome runs cmake and ninja directly with the arguments idf.py uses. This +runs the real ``idf.py reconfigure`` and ``idf.py build`` on a finished tree +(in place: CMake rejects a moved cache) and fails if either one changes the +cache, the generated build files or the firmware, or recompiles anything. +It catches drift when the pinned ESP-IDF version changes what idf.py does. +The color and ``size`` environment only change what is printed, so those +parts of the contract are pinned by the unit tests instead. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +WATCHED = ( + "build/CMakeCache.txt", + "build/build.ninja", + "build/compile_commands.json", + "build/project_description.json", + "build/config/sdkconfig.h", + "build/bootloader/bootloader.bin", +) +# Ninja logs whose outputs mean real work when their recorded mtime changes. +# The top level re-logs the bootloader step's byproducts on every build, so +# the bootloader is judged by its own sub-build log instead. +TOP_NINJA_LOG = "build/.ninja_log" +NINJA_LOGS = (TOP_NINJA_LOG, "build/bootloader/.ninja_log") +BOOTLOADER_BYPRODUCT = re.compile(r"(^|/build/)bootloader/") +WORK_SUFFIXES = (".obj", ".o", ".a", ".elf", ".map", ".bin", ".ld") +DEFAULT_GLOB = "tests/test_build_components/build/.esphome/build/*" + + +def _digest(path: Path) -> str | None: + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def watched(name: str) -> list[str]: + """Files that change if idf.py configures or builds differently.""" + return [*WATCHED, f"build/{name}.elf", f"build/{name}.bin"] + + +def _snapshot(build_path: Path, name: str) -> dict[str, str | None]: + return {f: _digest(build_path / f) for f in watched(name)} + + +def _ninja_mtimes(build_path: Path) -> dict[tuple[str, str], str]: + """(log, output) -> recorded mtime; compaction-safe, unlike a line count.""" + mtimes = {} + for name in NINJA_LOGS: + log = build_path / name + lines = log.read_text(encoding="utf-8").splitlines() if log.is_file() else [] + for fields in (line.split("\t") for line in lines if not line.startswith("#")): + if len(fields) >= 4 and ( + name != TOP_NINJA_LOG or not BOOTLOADER_BYPRODUCT.search(fields[3]) + ): + mtimes[name, fields[3]] = fields[2] + return mtimes + + +def _log_problems(build_path: Path, mtimes: dict[tuple[str, str], str]) -> list[str]: + """A missing or unparsable ninja log would otherwise compare as unchanged.""" + problems = [] + for log in NINJA_LOGS: + if not (build_path / log).is_file(): + problems.append(f"missing {log}") + elif not any(k[0] == log and k[1].endswith(WORK_SUFFIXES) for k in mtimes): + problems.append(f"no build entries parsed from {log}") + return problems + + +def _setup_core(build_path: Path, description: dict) -> tuple[str, str]: + """Point CORE at the tree so ESPHome resolves the same IDF env as the build.""" + from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT + import esphome.config_validation as cv + from esphome.core import CORE + + name = description["project_name"] + version = Path(description["idf_path"]).name + CORE.config_path = build_path.parents[2] / f"{name}.yaml" + CORE.build_path = build_path + CORE.name = name + CORE.data[KEY_ESP32] = { + KEY_IDF_VERSION: cv.Version.parse(version), + KEY_VARIANT: description["target"].upper(), + } + return name, version + + +def check(build_path: Path) -> list[str]: + """Return the problems found in one build tree.""" + # pylint: disable=protected-access + from esphome.espidf import toolchain + + description = json.loads( + (build_path / "build" / "project_description.json").read_text(encoding="utf-8") + ) + name, version = _setup_core(build_path, description) + env = toolchain._get_idf_env(version) + python = toolchain._get_idf_tool("python") + idf_py = toolchain._get_idf_path(version) / "tools" / "idf.py" + sdkconfig = build_path / f"sdkconfig.{name}" + sdkconfig_args = ["-D", f"SDKCONFIG={sdkconfig}"] if sdkconfig.is_file() else [] + + # CMake writes a different build.ninja on a tree's first configure than on + # a reconfigure, so the baseline is ESPHome's own reconfigure and build. + if (rc := toolchain.run_reconfigure()) != 0: + return [f"ESPHome's CMake configure failed with exit code {rc}"] + if (rc := toolchain._run_ninja("all", verbose=False, jobs=None)) != 0: + return [f"ESPHome's ninja build failed with exit code {rc}"] + before = _snapshot(build_path, name) + mtimes_before = _ninja_mtimes(build_path) + # A moved or renamed output would otherwise compare as "unchanged". + problems = [f"missing {f}" for f, digest in before.items() if digest is None] + if problems := problems + _log_problems(build_path, mtimes_before): + return problems + for action in ("reconfigure", "build"): + result = subprocess.run( + [python, str(idf_py), *sdkconfig_args, action], + cwd=build_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [f"idf.py {action} failed:\n{result.stdout}{result.stderr}"] + after = _snapshot(build_path, name) + mtimes_after = _ninja_mtimes(build_path) + problems = [f"idf.py changed {f}" for f in before if before[f] != after[f]] + problems += _log_problems(build_path, mtimes_after) + for key in sorted(mtimes_before.keys() | mtimes_after.keys()): + log, out = key + if not out.endswith(WORK_SUFFIXES): + continue + if key not in mtimes_after: + problems.append(f"idf.py dropped {out} from {log}") + elif mtimes_before.get(key) != mtimes_after[key]: + problems.append(f"idf.py rebuilt {out}") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument( + "build_paths", + nargs="*", + type=Path, + help=f"ESPHome build dirs (default: the first native ESP-IDF tree in {DEFAULT_GLOB})", + ) + args = parser.parse_args() + paths = args.build_paths or sorted(REPO_ROOT.glob(DEFAULT_GLOB)) + # Not resolved: SDKCONFIG must be spelled as the build spelled it. + trees = [ + p + for p in paths + if (p / "build" / "project_description.json").is_file() + and (p / "build" / "CMakeCache.txt").is_file() + ] + if rejected := [p for p in args.build_paths if p not in trees]: + for path in rejected: + print(f"{path}: not a configured native ESP-IDF build tree") + return 1 + if not trees: + print("No native ESP-IDF build tree found") + return 1 + if not args.build_paths: + # The contract does not depend on the target, so one tree is enough. + trees = trees[:1] + + failed = False + for tree in trees: + problems = check(tree) + print(f"{tree}: {'OK' if not problems else 'DIFFERS'}") + for problem in problems: + print(f" {problem}") + failed |= bool(problems) + if failed: + print( + "The direct cmake/ninja build no longer matches idf.py. Compare " + "_IdfPyContract and its users in esphome/espidf/toolchain.py with the " + "pinned ESP-IDF tools/idf_py_actions." + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index e3a0ff688fb..bd127a7a435 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -592,10 +592,17 @@ def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: ) +# Checks the native ESP-IDF build in CI but does not shape it, so it is kept +# out of ESP_IDF_INFRA_TRIGGER_FILES (hashed into the clang-tidy cache key). +_ESP_IDF_CHECK_TRIGGER_FILES = frozenset({"script/check_idf_py_equivalence.py"}) + + def _esp_idf_infra_changed(files: list[str]) -> bool: """Whether any changed file is ESP-IDF build/runner infrastructure.""" return _path_or_file_trigger( - files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + files, + ESP_IDF_INFRA_TRIGGER_FILES | _ESP_IDF_CHECK_TRIGGER_FILES, + ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES, ) @@ -1533,19 +1540,34 @@ def main() -> None: # Convert batches to CI matrix entries: the component list plus which # native toolchain installs the batch's test platforms need, so the # workflow only restores the matching multi-GB toolchain caches. + # The idf.py check does not depend on the components, so it runs once + # per workflow, in the first batch that compiles an esp32 test (a + # validate-only component is never compiled). + idf_py_check_assigned = False + skip_compile = set(validate_only_components) for batch in batches: platforms: set[str] = set() + compiled_platforms: set[str] = set() for component in batch: # Variants included: the compile stage builds them, so a # component tested only by test-..yaml # still needs that platform's toolchain - platforms.update( - get_component_test_platforms(component, base_only=False) + component_platforms = get_component_test_platforms( + component, base_only=False ) + platforms.update(component_platforms) + if component not in skip_compile: + compiled_platforms.update(component_platforms) + needs_idf = any(p.startswith("esp32") for p in platforms) + check_idf_py = not idf_py_check_assigned and any( + p.startswith("esp32") for p in compiled_platforms + ) + idf_py_check_assigned |= check_idf_py component_test_batches.append( { "components": " ".join(batch), - "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_idf": needs_idf, + "check_idf_py": check_idf_py, "needs_nrf": any(p.startswith("nrf52") for p in platforms), "needs_arduino8266": any( p.startswith("esp8266") for p in platforms diff --git a/tests/script/test_check_idf_py_equivalence.py b/tests/script/test_check_idf_py_equivalence.py new file mode 100644 index 00000000000..f8135772d0c --- /dev/null +++ b/tests/script/test_check_idf_py_equivalence.py @@ -0,0 +1,248 @@ +"""Tests for script/check_idf_py_equivalence.py.""" + +from collections.abc import Callable, Iterator +import json +from pathlib import Path +import subprocess +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import check_idf_py_equivalence as guard # noqa: E402 + +from esphome.core import CORE # noqa: E402 +from esphome.espidf import toolchain # noqa: E402 + + +@pytest.fixture(autouse=True) +def _reset_core() -> Iterator[None]: + """check() points the global CORE at the tree it inspects.""" + yield + CORE.reset() + + +def _make_tree(tmp_path: Path) -> Path: + tree = tmp_path / "config" / ".esphome" / "build" / "dev" + build = tree / "build" + for name in (*guard.watched("dev"), *guard.NINJA_LOGS): + (tree / name).parent.mkdir(parents=True, exist_ok=True) + (tree / name).write_bytes(b"x") + (build / "project_description.json").write_text( + json.dumps( + { + "project_name": "dev", + "idf_path": "/idf/frameworks/5.5.5", + "target": "esp32", + } + ) + ) + (build / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t10\tesp-idf/a.obj\t0\n" + "1\t2\t10\tbootloader/bootloader.bin\t0\n" + ) + (build / "bootloader" / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t10\tbootloader.elf\t0\n" + ) + (tree / "sdkconfig.dev").write_text("") + return tree + + +def _run_check( + tree: Path, + side_effect: Callable[[list[str]], None] = lambda cmd: None, + rc: int = 0, + esphome_rcs: tuple[int, int] = (0, 0), +) -> tuple[list[str], list[list[str]]]: + """Run check() with idf.py replaced by ``side_effect``; return problems, calls.""" + calls: list[list[str]] = [] + + def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess: + calls.append(cmd) + side_effect(cmd) + return subprocess.CompletedProcess(cmd, rc, "out\n", "err\n") + + with ( + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain, "_get_idf_tool", return_value="/py"), + patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), + patch.object(toolchain, "run_reconfigure", return_value=esphome_rcs[0]), + patch.object(toolchain, "_run_ninja", return_value=esphome_rcs[1]), + patch.object(guard.subprocess, "run", side_effect=run), + ): + return guard.check(tree), calls + + +def test_check_passes_when_idf_py_changes_nothing(tmp_path: Path) -> None: + tree = _make_tree(tmp_path) + problems, calls = _run_check(tree) + assert problems == [] + sdkconfig = f"SDKCONFIG={tree / 'sdkconfig.dev'}" + assert calls == [ + ["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "reconfigure"], + ["/py", str(Path("/idf/tools/idf.py")), "-D", sdkconfig, "build"], + ] + + +def test_check_reports_changed_files_and_rebuilt_outputs(tmp_path: Path) -> None: + tree = _make_tree(tmp_path) + build = tree / "build" + + def drift(cmd: list[str]) -> None: + if cmd[-1] == "reconfigure": + (build / "CMakeCache.txt").write_text("changed") + return + # Compacted log. The re-logged bootloader byproduct and a stamp are + # not work; a new object mtime is. + (build / ".ninja_log").write_text( + "# ninja log v7\n3\t4\t20\tesp-idf/a.obj\t0\n" + "5\t6\t30\tbootloader/bootloader.bin\t0\n" + "5\t6\t30\tbootloader-stamp\t0\n" + ) + # The bootloader sub-build is judged by its own log. + (build / "bootloader" / ".ninja_log").write_text( + "# ninja log v7\n1\t2\t40\tbootloader.elf\t0\n" + ) + + problems, _ = _run_check(tree, drift) + assert problems == [ + "idf.py changed build/CMakeCache.txt", + "idf.py rebuilt esp-idf/a.obj", + "idf.py rebuilt bootloader.elf", + ] + + +@pytest.mark.parametrize( + ("after_build", "problem"), + [ + (lambda log: log.unlink(), "missing build/.ninja_log"), + ( + lambda log: log.write_text("# ninja log v7\n"), + "no build entries parsed from build/.ninja_log", + ), + ( + lambda log: log.write_text("# ninja log v7\n1\t2\t10\tesp-idf/b.obj\t0\n"), + "idf.py dropped esp-idf/a.obj from build/.ninja_log", + ), + ], + ids=["log-removed", "log-emptied", "entry-dropped"], +) +def test_check_reports_a_log_idf_py_left_unusable( + tmp_path: Path, after_build: Callable[[Path], None], problem: str +) -> None: + """The comparison side gets the same log checks as the baseline.""" + tree = _make_tree(tmp_path) + log = tree / guard.TOP_NINJA_LOG + + def run(cmd: list[str]) -> None: + if cmd[-1] == "build": + after_build(log) + + problems, _ = _run_check(tree, run) + assert problem in problems + + +def test_check_stops_when_idf_py_fails(tmp_path: Path) -> None: + problems, calls = _run_check(_make_tree(tmp_path), rc=2) + assert problems == ["idf.py reconfigure failed:\nout\nerr\n"] + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "remove", ["build/build.ninja", "build/dev.bin", *guard.NINJA_LOGS] +) +def test_check_fails_when_an_input_is_missing(tmp_path: Path, remove: str) -> None: + """A moved or renamed output must not compare as unchanged.""" + tree = _make_tree(tmp_path) + (tree / remove).unlink() + problems, calls = _run_check(tree) + assert problems == [f"missing {remove}"] + assert calls == [] + + +@pytest.mark.parametrize( + ("esphome_rcs", "problem"), + [ + ((3, 0), "ESPHome's CMake configure failed with exit code 3"), + ((0, 4), "ESPHome's ninja build failed with exit code 4"), + ], + ids=["configure", "build"], +) +def test_check_stops_when_the_esphome_baseline_fails( + tmp_path: Path, esphome_rcs: tuple[int, int], problem: str +) -> None: + """The baseline is ESPHome's own reconfigure and build.""" + problems, calls = _run_check(_make_tree(tmp_path), esphome_rcs=esphome_rcs) + assert problems == [problem] + assert calls == [] + + +@pytest.mark.parametrize("log", guard.NINJA_LOGS) +def test_check_fails_when_a_ninja_log_has_no_entries(tmp_path: Path, log: str) -> None: + """A log format change must not leave the rebuild check with nothing to compare.""" + tree = _make_tree(tmp_path) + (tree / log).write_text("# ninja log v99\n1 2 3\n") + problems, calls = _run_check(tree) + assert problems == [f"no build entries parsed from {log}"] + assert calls == [] + + +def test_main_rejects_a_path_that_is_not_a_tree( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + tree = _make_tree(tmp_path / "a") + stale = tmp_path / "stale" + with ( + patch.object(sys, "argv", ["check", str(tree), str(stale)]), + patch.object(guard, "check", return_value=[]) as mock_check, + ): + assert guard.main() == 1 + assert f"{stale}: not a configured native ESP-IDF build tree" in ( + capsys.readouterr().out + ) + mock_check.assert_not_called() + + +def test_main_without_build_trees( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with ( + patch.object(sys, "argv", ["check"]), + patch.object(guard, "REPO_ROOT", tmp_path), + ): + assert guard.main() == 1 + assert "No native ESP-IDF build tree found" in capsys.readouterr().out + + +@pytest.mark.parametrize(("problems", "rc"), [([], 0), (["idf.py changed x"], 1)]) +def test_main_reports_each_tree( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + problems: list[str], + rc: int, +) -> None: + tree = _make_tree(tmp_path) + with ( + patch.object(sys, "argv", ["check", str(tree)]), + patch.object(guard, "check", return_value=problems), + ): + assert guard.main() == rc + out = capsys.readouterr().out + assert f"{tree}: {'DIFFERS' if problems else 'OK'}" in out + assert ("no longer matches idf.py" in out) is bool(problems) + + +def test_main_checks_only_the_first_found_tree(tmp_path: Path) -> None: + """The contract does not depend on the target; one tree per batch is enough.""" + first = _make_tree(tmp_path / "a") + _make_tree(tmp_path / "b") + with ( + patch.object(sys, "argv", ["check"]), + patch.object(guard, "REPO_ROOT", tmp_path), + patch.object(guard, "DEFAULT_GLOB", "*/config/.esphome/build/*"), + patch.object(guard, "check", return_value=[]) as mock_check, + ): + assert guard.main() == 0 + mock_check.assert_called_once_with(first) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 49f101d6792..cea2a2bfe6f 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -249,11 +249,61 @@ def test_main_all_tests_should_run( # Should contain at least one component (no empty batches) assert len(batch["components"]) > 0 assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["check_idf_py"], bool) assert isinstance(batch["needs_nrf"], bool) assert isinstance(batch["needs_arduino8266"], bool) -def test_main_batch_flags_count_variant_tests( +def _batch(components: str, idf: bool, check: bool, a8266: bool) -> dict: + return { + "components": components, + "needs_idf": idf, + "check_idf_py": check, + "needs_nrf": False, + "needs_arduino8266": a8266, + } + + +@pytest.mark.parametrize( + ("batches", "component_platforms", "validate_only", "expected"), + [ + # The compile stage builds test-..yaml too, so a + # component tested on esp8266 only by a variant still needs the toolchain. + ( + [["safe_mode"]], + {"safe_mode": {"esp8266-ard"}}, + set(), + [_batch("safe_mode", idf=False, check=False, a8266=True)], + ), + # Only the first esp32 batch runs the idf.py equivalence check. + ( + [["a"], ["b"], ["c"]], + {"a": {"esp8266-ard"}, "b": {"esp32-idf"}, "c": {"esp32-c3-idf"}}, + set(), + [ + _batch("a", idf=False, check=False, a8266=True), + _batch("b", idf=True, check=True, a8266=False), + _batch("c", idf=True, check=False, a8266=False), + ], + ), + # A validate-only batch never compiles, so the next esp32 batch runs it. + ( + [["b"], ["c"]], + {"b": {"esp32-idf"}, "c": {"esp32-c3-idf"}}, + {"b"}, + [ + _batch("b", idf=True, check=False, a8266=False), + _batch("c", idf=True, check=True, a8266=False), + ], + ), + ], + ids=["variant", "idf-check-once", "idf-check-skips-validate-only"], +) +def test_main_batch_flags( + batches: list[list[str]], + component_platforms: dict[str, set[str]], + validate_only: set[str], + expected: list[dict], mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, @@ -267,8 +317,7 @@ def test_main_batch_flags_count_variant_tests( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: - """The compile stage builds test-..yaml too, so a - component tested on esp8266 only by a variant still needs the toolchain.""" + """Each batch carries the toolchain flags its test platforms need.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = False @@ -282,13 +331,20 @@ def test_main_batch_flags_count_variant_tests( mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"] def platforms(component: str, *, base_only: bool = True) -> set[str]: - return set() if base_only else {"esp8266-ard"} + return set() if base_only else component_platforms[component] with ( patch("sys.argv", ["determine-jobs.py"]), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), patch.object( - determine_jobs, "get_changed_components", return_value=["safe_mode"] + determine_jobs, + "get_changed_components", + return_value=[c for batch in batches for c in batch], + ), + patch.object( + determine_jobs, + "_component_change_is_validate_only", + side_effect=lambda component, changed: component in validate_only, ), patch.object( determine_jobs, @@ -298,7 +354,7 @@ def test_main_batch_flags_count_variant_tests( patch.object( determine_jobs, "get_components_with_dependencies", - return_value=["safe_mode"], + return_value=[c for batch in batches for c in batch], ), patch.object(determine_jobs, "_component_has_tests", return_value=True), patch.object( @@ -307,9 +363,7 @@ def test_main_batch_flags_count_variant_tests( return_value={"should_run": "false"}, ), patch.object( - determine_jobs, - "create_intelligent_batches", - return_value=([["safe_mode"]], {}), + determine_jobs, "create_intelligent_batches", return_value=(batches, {}) ), patch.object( determine_jobs, "get_component_test_platforms", side_effect=platforms @@ -318,14 +372,7 @@ def test_main_batch_flags_count_variant_tests( determine_jobs.main() output = json.loads(capsys.readouterr().out) - assert output["component_test_batches"] == [ - { - "components": "safe_mode", - "needs_idf": False, - "needs_nrf": False, - "needs_arduino8266": True, - } - ] + assert output["component_test_batches"] == expected def test_main_no_tests_should_run( @@ -1231,8 +1278,10 @@ def test_should_run_esp32_platformio_with_branch() -> None: @pytest.mark.parametrize( ("changed_files", "expected"), [ - # ESP-IDF runner / framework / build generator -> trigger - (["esphome/espidf/runner.py"], True), + # ESP-IDF toolchain / framework / build generator -> trigger + (["esphome/espidf/toolchain.py"], True), + (["esphome/build_helpers/tool_runner.py"], True), + (["script/check_idf_py_equivalence.py"], True), (["esphome/espidf/framework.py"], True), (["esphome/build_gen/espidf.py"], True), # Shared native-build modules the IDF build imports -> trigger @@ -1250,7 +1299,7 @@ def test_should_run_esp32_platformio_with_branch() -> None: ], ) def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: - """ESP-IDF build/runner infra paths are detected; other paths are not.""" + """ESP-IDF build infra paths are detected; other paths are not.""" assert determine_jobs._esp_idf_infra_changed(changed_files) is expected diff --git a/tests/unit_tests/build_helpers/test_tool_runner.py b/tests/unit_tests/build_helpers/test_tool_runner.py new file mode 100644 index 00000000000..e2c7e44341a --- /dev/null +++ b/tests/unit_tests/build_helpers/test_tool_runner.py @@ -0,0 +1,338 @@ +"""Tests for esphome.build_helpers.tool_runner.""" + +# pylint: disable=protected-access + +import io +import os +from pathlib import Path +import sys +import threading +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import tool_runner +from esphome.build_helpers.tool_runner import ( + UTF8_CODEPAGE, + ToolOutput, + Utf8Console, + run_build_tool, +) +from esphome.core import CORE + +FILTER = [r"-- Component paths:", r"\s*$"] + + +def _child(code: str) -> list[str]: + return [sys.executable, "-c", code] + + +def _run( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + code: str, + **kwargs: object, +) -> tuple[int, str]: + rc = run_build_tool(_child(code), cwd=tmp_path, env=dict(os.environ), **kwargs) + # The child's print() ends lines with \r\n on Windows. + return rc, capsys.readouterr().out.replace("\r\n", "\n") + + +def test_run_build_tool_filters_and_returns_exit_code( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rc, out = _run( + capsys, + tmp_path, + "import sys\n" + "print('-- Component paths: /a /b')\n" + "print('')\n" + "print('Compiling main.cpp')\n" + "sys.exit(3)", + filter_lines=FILTER, + ) + assert rc == 3 + assert out == "Compiling main.cpp\n" + + +def test_run_build_tool_passes_everything_without_filter( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rc, out = _run(capsys, tmp_path, "print('-- Component paths: /a')\nprint('')") + assert rc == 0 + assert out == "-- Component paths: /a\n\n" + + +def test_run_build_tool_merges_stderr_and_runs_in_cwd( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + _, out = _run( + capsys, + tmp_path, + "import os, sys\nprint(os.getcwd(), flush=True)\nprint('oops', file=sys.stderr)", + filter_lines=FILTER, + ) + assert out.splitlines() == [os.path.realpath(tmp_path), "oops"] + + +def test_run_build_tool_drains_a_partial_line( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """A tool that dies mid line still shows that line, terminated.""" + rc, out = _run( + capsys, + tmp_path, + "import sys\nsys.stdout.write('ld returned 1 exit status')\nsys.exit(1)", + filter_lines=FILTER, + ) + assert rc == 1 + assert out == "ld returned 1 exit status\n" + + +def test_run_build_tool_replaces_invalid_utf8( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + _, out = _run( + capsys, + tmp_path, + # A multi-byte character split across writes must survive too. + "import sys\nb = sys.stdout.buffer\n" + "b.write(b'\\xc3'); b.flush(); b.write(b'\\xa9 ok\\n\\xff bad\\n')", + filter_lines=FILTER, + ) + assert out == "é ok\n� bad\n" + + +def test_run_build_tool_streams_before_the_tool_exits(tmp_path: Path) -> None: + """Output must reach the user while the tool runs, not when it ends.""" + seen = threading.Event() + lines: list[str] = [] + + class _Out(io.StringIO): + def write(self, s: str) -> int: + lines.append(s) + if "first" in s: + seen.set() + return len(s) + + release = tmp_path / "release" + code = ( + "import os, time\nprint('first', flush=True)\n" + f"while not os.path.exists({str(release)!r}): time.sleep(0.02)\n" + ) + with patch.object(tool_runner.sys, "stdout", _Out()): + thread = threading.Thread( + target=run_build_tool, + args=(_child(code),), + kwargs={"cwd": tmp_path, "env": dict(os.environ), "filter_lines": FILTER}, + ) + thread.start() + try: + assert seen.wait(30) + finally: + release.touch() + thread.join(30) + assert "first" in "".join(lines) + + +def _tool_output(progress: bool = True) -> tuple[ToolOutput, io.StringIO]: + out = io.StringIO() + return ToolOutput(out, FILTER, progress), out + + +def test_tool_output_collapses_progress_lines() -> None: + """Ninja status lines overwrite each other, as idf.py shows them.""" + output, out = _tool_output() + output.write("[1/2] Building a.o\n[2/2] Linking app\nwarning: x\n") + assert out.getvalue() == ( + "\r[1/2] Building a.o\x1b[K\r[2/2] Linking app\x1b[K" + + os.linesep + + "warning: x\n" + ) + + +def test_tool_output_drain_ends_a_progress_line() -> None: + output, out = _tool_output() + output.write("[1/1] Linking app\n") + output.drain() + assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep + # Nothing is pending any more. + output.drain() + assert out.getvalue().count(os.linesep) == 1 + + +def test_tool_output_drain_survives_a_broken_stream() -> None: + output, out = _tool_output() + output.write("[1/1] Linking app\n") + out.close() + output.drain() + + +def test_tool_output_filters_a_matching_progress_line() -> None: + out = io.StringIO() + output = ToolOutput(out, [r"\[1/2\]"], True) + output.write("[1/2] hidden\n[2/2] shown\n") + assert out.getvalue() == "\r[2/2] shown\x1b[K" + + +def test_tool_output_without_progress_keeps_status_lines() -> None: + output, out = _tool_output(progress=False) + output.write("[1/2] Building a.o\n") + assert out.getvalue() == "[1/2] Building a.o\n" + + +def test_tool_output_escapes_colors_for_the_dashboard() -> None: + CORE.dashboard = True + output, out = _tool_output() + output.write("[1/1] \x1b[1mLinking\x1b[0m\n") + assert "\x1b" not in out.getvalue() + assert "\\033[K" in out.getvalue() + + +@pytest.mark.parametrize( + ("width", "text", "expected"), + [ + (0, "x" * 50, "x" * 50), + (3, "abcdef", "..."), + (20, "short", "short"), + (11, "abcdefghijklmnop", "abcd...mnop"), + ], + ids=["unknown", "tiny", "fits", "elided"], +) +def test_fit_terminal(width: int, text: str, expected: str) -> None: + with patch.object( + tool_runner.shutil, + "get_terminal_size", + return_value=os.terminal_size((width, 24)), + ): + assert tool_runner._fit_terminal(text) == expected + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_run_build_tool_switches_the_console_to_utf8( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """An attached console runs the tool on UTF-8 and is then put back.""" + kernel32 = _FakeKernel32(850, 850) + with patch.object(tool_runner, "_get_kernel32", return_value=kernel32): + _run(capsys, tmp_path, "print('x')") + assert kernel32.calls == [ + ("SetConsoleCP", UTF8_CODEPAGE), + ("SetConsoleOutputCP", UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_utf8_console_restores_after_an_error() -> None: + kernel32 = _FakeKernel32(437, 437) + with pytest.raises(RuntimeError), Utf8Console(kernel32): + raise RuntimeError + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_utf8_console_restores_when_the_switch_fails_part_way() -> None: + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + with Utf8Console(kernel32): + pass + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +@pytest.mark.parametrize( + "codepages", [(0, 0), (UTF8_CODEPAGE, UTF8_CODEPAGE)], ids=["none", "utf8"] +) +def test_utf8_console_leaves_the_console_alone(codepages: tuple[int, int]) -> None: + """No console, or one already on UTF-8 (an overlapping build), is untouched.""" + kernel32 = _FakeKernel32(*codepages) + with Utf8Console(kernel32): + pass + assert kernel32.calls == [] + + +def test_utf8_console_without_kernel32() -> None: + with Utf8Console(None): + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert tool_runner._get_kernel32() is None + + +def test_tool_output_collapses_progress_without_a_filter() -> None: + """Progress mode splits lines on its own; it does not need a filter.""" + out = io.StringIO() + output = ToolOutput(out, None, True) + output.write("[1/1] Linking app\ndone\n") + assert out.getvalue() == "\r[1/1] Linking app\x1b[K" + os.linesep + "done\n" + + +def test_run_build_tool_flushes_a_truncated_character( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """Output that ends inside a multi-byte character still shows up.""" + _, out = _run( + capsys, + tmp_path, + "import sys\nsys.stdout.buffer.write(b'end \\xc3')", + filter_lines=FILTER, + ) + assert out == "end �\n" + + +def test_get_kernel32_loads_it_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + kernel32 = object() + fake_ctypes = SimpleNamespace(windll=SimpleNamespace(kernel32=kernel32)) + monkeypatch.setattr(tool_runner.sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes) + assert tool_runner._get_kernel32() is kernel32 + + +def test_run_build_tool_logs_the_unfiltered_output( + capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + """The log gets every line, filtered or not, for idf.py's hint matcher.""" + log = tmp_path / "log" / "ninja_all_output.log" + _, out = _run( + capsys, + tmp_path, + "print('-- Component paths: /a')\nprint('\\x1b[1merror:\\x1b[0m boom')", + filter_lines=FILTER, + log_path=log, + ) + assert out == "\x1b[1merror:\x1b[0m boom\n" + assert log.read_text(encoding="utf-8").replace("\r\n", "\n") == ( + "-- Component paths: /a\nerror: boom\n" + ) diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py deleted file mode 100644 index a77d5c8f281..00000000000 --- a/tests/unit_tests/fixtures/espidf/closing_probe.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Leave a partial line behind and then close the stream under the runner. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining -cannot work here; the point is that the failure is reported rather than -raised out of the runner's cleanup, where it would bury the exit code. -""" - -import sys - -sys.stdout.write("partial before close") -sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py deleted file mode 100644 index bf434cc24e1..00000000000 --- a/tests/unit_tests/fixtures/espidf/crashing_probe.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Die part way through a line, the way a build that blows up does. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -message has no trailing newline, so the runner's shim is holding it when -the process exits; nothing else will ever come to release it. -""" - -import sys - -sys.stdout.write("FATAL: ld returned 1 exit status") -sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py deleted file mode 100644 index 04c2b2ed8cc..00000000000 --- a/tests/unit_tests/fixtures/espidf/filtering_probe.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Write a mix of noisy and useful build lines, without flushing. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -runner's shim owns both the filtering and the flushing, so this script -only writes. -""" - -import sys - -sys.stdout.write("Project build complete.\n") -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("-- Component paths: /a /b /c\n") -sys.stdout.write("[2/9] Building C object\n") -# No terminator, so the shim has to hold this one back. -sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py deleted file mode 100644 index 727cda25ce7..00000000000 --- a/tests/unit_tests/fixtures/espidf/formfeed_probe.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Write a form feed part way through the output. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form -feed is not a line terminator here, so everything written must still come -out, including the complete lines that follow it. -""" - -import sys - -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("page one\x0cpage two\n") -sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py deleted file mode 100644 index 9c81f8eb7b3..00000000000 --- a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py +++ /dev/null @@ -1,10 +0,0 @@ -"""End on an unterminated line that the filter is supposed to drop. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to -check that releasing a held-back line still applies the filter. -""" - -import sys - -sys.stdout.write("Compiling main.cpp\n") -sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py deleted file mode 100644 index c05741e3117..00000000000 --- a/tests/unit_tests/fixtures/espidf/streaming_probe.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Print one line, then stay alive so the caller can prove it streamed. - -Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The -runner wraps stdout in its filtering shim, so this script deliberately -does not flush: the shim has to do it. The long sleep keeps the process -running, so anything the caller reads must have arrived while the build -was still going rather than at exit. -""" - -import sys -import time - -sys.stdout.write("Compiling main.cpp\n") -time.sleep(60) diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 4cc445e29be..e0cfec88c85 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -1,5 +1,7 @@ """Tests for esphome.espidf.clang_tidy tidy-project generation.""" +# pylint: disable=protected-access + import json import os from pathlib import Path @@ -99,3 +101,39 @@ def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None: compile_commands.write_text(json.dumps([])) with pytest.raises(RuntimeError, match="tidy.cpp not found"): clang_tidy._idedata_from_tidy_project(compile_commands) + + +@pytest.mark.parametrize( + ("reconfigure_rcs", "error"), + [ + ((1,), "ESP-IDF CMake configure \\(discovery\\) failed"), + ((0, 1), "ESP-IDF CMake configure failed"), + ((0, 0), None), + ], + ids=["discovery", "full", "ok"], +) +def test_generate_compile_commands_configures_twice( + tmp_path: Path, reconfigure_rcs: tuple[int, ...], error: str | None +) -> None: + """Discovery configure, then a configure requiring what it found.""" + with ( + patch.object(clang_tidy, "_setup_core"), + patch.object(clang_tidy, "_convert_pio_libs", return_value={}), + patch.object(clang_tidy, "_write_tidy_project") as mock_write, + patch("esphome.espidf.toolchain.run_reconfigure", side_effect=reconfigure_rcs), + patch( + "esphome.build_gen.espidf.get_available_components", + return_value=["lwip", "esp_timer"], + ), + ): + if error: + with pytest.raises(RuntimeError, match=error): + clang_tidy._generate_compile_commands( + tmp_path, _settings(), tmp_path / "platformio.ini" + ) + return + result = clang_tidy._generate_compile_commands( + tmp_path, _settings(), tmp_path / "platformio.ini" + ) + assert result == tmp_path / "build" / "compile_commands.json" + assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"] diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 9654c7cfc57..c785d56fa41 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1627,8 +1627,8 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: # short-circuits before build_path is needed. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: - # The canonical off spelling is exported: the raw value is inherited - # by idf.py, where a spelling like "disable" would read as truthy + # The canonical off spelling is exported, so every reader of the + # env sees the same answer assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} @@ -1636,7 +1636,7 @@ def test_ccache_env_opt_in_without_binary( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: # Explicit IDF_CCACHE_ENABLE=1 forces it on; without a usable binary - # idf.py silently skips ccache, so this branch must say so out loud. + # IDF's CMake silently skips ccache, so this branch must say so out loud. p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: env = _ccache_env() @@ -1669,7 +1669,7 @@ def test_ccache_env_opt_in_with_working_binary( def test_ccache_env_opt_in_with_rejected_binary( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - # Forced on with a present-but-rejected binary: idf.py does its own + # Forced on with a present-but-rejected binary: IDF's CMake does its own # PATH lookup and uses it anyway; the warning must say so, not claim # the build runs without ccache. # A present but non-executable file: the real probe fails and logs @@ -1686,7 +1686,7 @@ def test_ccache_env_opt_in_with_rejected_binary( ): env = _ccache_env() assert env["IDF_CCACHE_ENABLE"] == "1" - assert "idf.py will use it anyway" in caplog.text + assert "the build will use it anyway" in caplog.text # Exactly one story: the resolver's contradictory "compiling without # ccache" must not precede it assert "compiling without ccache" not in caplog.text @@ -1715,7 +1715,7 @@ def test_ccache_env_idf_knob_unrecognized_warns_and_defers( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver, - and is not forwarded to idf.py as truthy.""" + and is not forwarded to the build as truthy.""" p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") env_vars = {"IDF_CCACHE_ENABLE": "enabled"} with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3: diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py deleted file mode 100644 index 71ccfee1f2c..00000000000 --- a/tests/unit_tests/test_espidf_runner.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Tests for esphome.espidf.runner.""" - -from __future__ import annotations - -import io -import os -from pathlib import Path -import subprocess -import sys -import threading - -import pytest - -from esphome.espidf import runner - -# A flushing runner delivers the first line in well under a second; this is -# only ever waited out when the shim has gone back to buffering, so keep it -# just long enough to cover interpreter startup on a loaded CI machine. -FIRST_LINE_TIMEOUT = 10.0 - - -def _prepare_main( - monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str -) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Point ``runner.main()`` at *probe* with a buffered fake stdout. - - ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and - ``os.get_terminal_size``; every one of those is monkeypatched so it is - put back afterwards. The fake stdout is block buffered like a pipe, so - the caller can tell whether the shim flushed. The wrapper comes back with - the buffer because dropping it would close the buffer underneath us. - """ - buf = io.BytesIO() - stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) - - monkeypatch.setattr(sys, "path", list(sys.path)) - monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) - monkeypatch.setattr(sys, "stdout", stream) - monkeypatch.setattr(sys, "stderr", stream) - monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) - - return buf, stream - - -def _run_main( - monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str -) -> tuple[io.BytesIO, io.TextIOWrapper]: - """Run ``runner.main()`` against *probe* and expect a clean exit.""" - buf, stream = _prepare_main(monkeypatch, probe, *args) - assert runner.main() == 0 - return buf, stream - - -def test_main_filters_noise_and_flushes_each_write( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """Useful lines reach the stream right away; noisy ones are dropped.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "filtering_probe.py" - ) - - # Read before any flush of our own: the shim has to have flushed. - output = buf.getvalue().decode("utf-8") - - assert "Compiling main.cpp\n" in output - assert "[2/9] Building C object\n" in output - # Matched by FILTER_IDF_LINES, so they never leave the runner. - assert "Project build complete." not in output - assert "-- Component paths:" not in output - # Held back until the end because no terminator arrived. - assert output.endswith("still going\n") - - -def test_main_keeps_output_after_a_form_feed( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A form feed is text, not a line break, so nothing after it is lost.""" - buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") - - assert buf.getvalue().decode("utf-8") == ( - "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" - ) - - -def test_main_drains_a_partial_line_when_the_build_dies( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A build that stops mid line must still show that line. - - This is the whole point of draining: the message explaining why the - build failed is exactly the one most likely to arrive without a - trailing newline. - """ - buf, _stream = _prepare_main( - monkeypatch, fixture_path / "espidf" / "crashing_probe.py" - ) - - with pytest.raises(SystemExit) as excinfo: - runner.main() - - assert excinfo.value.code == 2 - assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" - - -def test_main_reports_rather_than_raises_when_draining_fails( - monkeypatch: pytest.MonkeyPatch, - fixture_path: Path, - capfd: pytest.CaptureFixture[str], -) -> None: - """A stream that closed under us must not crash the runner's cleanup. - - The drain runs from a ``finally``, so an exception there would replace - whatever exit code the build was carrying back. - """ - _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") - - assert runner.main() == 0 - reported = capfd.readouterr().err - assert "Could not write out remaining output" in reported - # The held line has to come along; the stream it was meant for is gone. - assert "partial before close" in reported - - -def test_main_survives_a_drain_failure_with_nowhere_to_report_it( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """With no real stderr to report to, cleanup still must not raise. - - ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` - falls back to ``sys.stdout``, which here is the shim wrapping the stream - that just failed. - """ - monkeypatch.setattr(sys, "__stderr__", None) - _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") - - assert runner.main() == 0 - - -def test_main_still_filters_a_drained_partial_line( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """Releasing a held line does not smuggle noise past the filter.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" - ) - - assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" - - -def test_main_keeps_everything_in_verbose_mode( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """``-v`` turns the filter off so the noisy lines survive.""" - buf, _stream = _run_main( - monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" - ) - - output = buf.getvalue().decode("utf-8") - - assert "Project build complete.\n" in output - assert "-- Component paths: /a /b /c\n" in output - # With no filter there is no line buffering, so the partial line goes - # straight through as well. - assert output.endswith("still going") - - -def test_runner_streams_output_before_the_build_finishes( - fixture_path: Path, probe_env: dict[str, str] -) -> None: - """The runner must flush, or a dashboard build looks frozen. - - ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and - hands it a pipe when esphome itself is running under the dashboard. A - pipe is block buffered, so without a flush in the shim's ``write()`` the - output sits in the child until 8 KiB piles up or the build ends. - """ - runner_py = Path(runner.__file__) - probe = fixture_path / "espidf" / "streaming_probe.py" - - with subprocess.Popen( - [sys.executable, str(runner_py), str(probe)], - stdout=subprocess.PIPE, - # Keep stderr: if the runner dies on startup, its traceback is the - # only clue about why no line showed up. - stderr=subprocess.PIPE, - env=probe_env, - text=True, - ) as proc: - assert proc.stdout is not None - assert proc.stderr is not None - first_line: list[str] = [] - reader = threading.Thread( - target=lambda: first_line.append(proc.stdout.readline()), daemon=True - ) - try: - reader.start() - reader.join(FIRST_LINE_TIMEOUT) - still_running = proc.poll() is None - - # The probe sleeps for a minute after writing, so reaching us at - # all means the line was flushed rather than released at exit. - assert first_line == ["Compiling main.cpp\n"], ( - f"runner stderr: {'' if still_running else proc.stderr.read()}" - ) - assert still_running - finally: - proc.kill() - proc.wait() - # Join before leaving the block, so the reader is done rather than - # racing ``Popen`` closing the pipe under it. - reader.join(1.0) - - -class _FakeKernel32: - """Stand-in for the Windows kernel32 console code page calls.""" - - def __init__(self, input_cp: int, output_cp: int) -> None: - self.input_cp = input_cp - self.output_cp = output_cp - self.calls: list[tuple[str, int]] = [] - - def GetConsoleCP(self) -> int: # noqa: N802 - return self.input_cp - - def GetConsoleOutputCP(self) -> int: # noqa: N802 - return self.output_cp - - def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 - self.calls.append(("SetConsoleCP", codepage)) - self.input_cp = codepage - return 1 - - def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 - self.calls.append(("SetConsoleOutputCP", codepage)) - self.output_cp = codepage - return 1 - - -def test_main_runs_the_build_with_a_utf8_console( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """An attached console is switched to UTF-8 and then put back.""" - kernel32 = _FakeKernel32(850, 850) - monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) - - _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") - - assert kernel32.calls == [ - ("SetConsoleCP", runner.UTF8_CODEPAGE), - ("SetConsoleOutputCP", runner.UTF8_CODEPAGE), - ("SetConsoleCP", 850), - ("SetConsoleOutputCP", 850), - ] - - -def test_main_restores_the_console_when_the_build_dies( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A failing build must not leave the user's console on UTF-8.""" - kernel32 = _FakeKernel32(437, 437) - monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) - _prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py") - - with pytest.raises(SystemExit): - runner.main() - - assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) - - -def test_main_restores_the_console_when_the_switch_fails_part_way( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """A failed output page switch must not strand the changed input page.""" - kernel32 = _FakeKernel32(850, 850) - - def _refuse(codepage: int) -> int: - kernel32.calls.append(("SetConsoleOutputCP", codepage)) - return 0 - - kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] - monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) - - _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") - - assert kernel32.input_cp == 850 - assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] - - -def test_main_leaves_the_console_alone_when_there_is_none( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """Without a console the code page calls return 0 and nothing is set.""" - kernel32 = _FakeKernel32(0, 0) - monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) - - _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") - - assert kernel32.calls == [] - - -def test_main_leaves_a_console_already_on_utf8_alone( - monkeypatch: pytest.MonkeyPatch, fixture_path: Path -) -> None: - """An overlapping build must not save UTF-8 as the page to restore.""" - kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE) - monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) - - _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") - - assert kernel32.calls == [] - - -@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") -def test_get_kernel32_is_none_off_windows() -> None: - assert runner._get_kernel32() is None diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 3e1e88d66da..ee21cd77a9c 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -8,11 +8,12 @@ import json import os from pathlib import Path import subprocess -from unittest.mock import patch +from unittest.mock import call, patch import pytest -from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT +import esphome.config_validation as cv from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, @@ -104,11 +105,27 @@ def _setup_build(setup_core: Path) -> tuple[Path, Path]: """Point CORE at a build dir; return (compile_commands, idedata cache) paths.""" CORE.name = "test" CORE.build_path = setup_core / "build" / "test" + CORE.data.setdefault(KEY_ESP32, {})[KEY_IDF_VERSION] = cv.Version(5, 5, 5) compile_commands = CORE.relative_build_path("build", "compile_commands.json") cache = CORE.relative_internal_path("idedata", "test.json") return compile_commands, cache +@contextmanager +def _up_to_date_compile(ninja_side_effect=None) -> Iterator[tuple]: + """Patch run_compile's staleness checks to "up to date"; yield the ninja + and print_summary mocks.""" + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object( + toolchain, "_run_ninja", return_value=0, side_effect=ninja_side_effect + ) as mock_ninja, + patch.object(toolchain, "print_summary") as mock_summary, + ): + yield mock_ninja, mock_summary + + 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.""" @@ -279,28 +296,6 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env( mock_run.assert_not_called() -def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None: - """The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS.""" - _setup_build(setup_core) - - with ( - patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")), - patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}), - patch.object(toolchain, "_get_idf_tool", return_value="python"), - patch.object(toolchain.subprocess, "run") as mock_run, - ): - mock_run.return_value.returncode = 0 - - toolchain.run_idf_py("build", jobs=2) - env = mock_run.call_args.kwargs["env"] - assert env["IDF_PY_BUILD_JOBS"] == "2" - assert env["PATH"] == "/bin" - - toolchain.run_idf_py("build") - env = mock_run.call_args.kwargs["env"] - 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 @@ -325,7 +320,7 @@ def test_run_compile_restamps_cmakecache_after_discovery(setup_core: Path) -> No ), 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, "_run_ninja", return_value=0), patch.object(toolchain, "print_summary"), ): assert toolchain.run_compile(config, verbose=False) == 0 @@ -350,7 +345,7 @@ def test_run_compile_discovery_without_cmakecache(setup_core: Path) -> None: ), 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, "_run_ninja", return_value=0), patch.object(toolchain, "print_summary"), ): assert toolchain.run_compile(config, verbose=False) == 0 @@ -376,7 +371,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( def record_write(minimal: bool = False, builtin_components=None) -> None: calls.append(("write_project", minimal)) - def record_reconfigure() -> int: + def record_reconfigure(verbose: bool = False) -> int: nonlocal reconfigures reconfigures += 1 calls.append(("run_reconfigure",)) @@ -391,7 +386,7 @@ def test_run_compile_reconfigures_after_full_write_outside_testing_mode( ), patch("esphome.build_gen.espidf.write_project", side_effect=record_write), patch.object(toolchain, "run_reconfigure", side_effect=record_reconfigure), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_build, + patch.object(toolchain, "_run_ninja", return_value=0) as mock_build, patch.object(toolchain, "print_summary"), ): assert not CORE.testing_mode @@ -421,7 +416,7 @@ def _record_compile_calls( calls: list[tuple] = [] rcs = iter(reconfigure_rcs) - def record_reconfigure() -> int: + def record_reconfigure(verbose: bool = False) -> int: calls.append(("run_reconfigure",)) return next(rcs, 0) @@ -431,6 +426,11 @@ def _record_compile_calls( def record_save(components: list[str]) -> None: calls.append(("save", components)) + def record_ninja(target: str, **kwargs: object) -> int: + if target == "all": + calls.append(("build",)) + return 0 + with ( patch.object(toolchain, "need_reconfigure", return_value=True), patch.object(toolchain, "load_cached_builtin_components", return_value=cached), @@ -443,11 +443,7 @@ def _record_compile_calls( patch.object( toolchain, "_builtin_component_cache_path", return_value=cache_file ), - patch.object( - toolchain, - "run_idf_py", - side_effect=lambda *a, **kw: calls.append(("build",)) or 0, - ), + patch.object(toolchain, "_run_ninja", side_effect=record_ninja), patch.object(toolchain, "print_summary"), ): rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) @@ -630,19 +626,21 @@ def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) assert toolchain.load_cached_builtin_components() is None -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.""" +@pytest.mark.parametrize("limit", [1, None]) +def test_run_compile_passes_compile_process_limit( + setup_core: Path, limit: int | None +) -> None: + """compile_process_limit is the job limit for both ninja runs.""" _setup_build(setup_core) - config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}} + esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit} - with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, - patch.object(toolchain, "print_summary"), - ): - assert toolchain.run_compile(config, verbose=False) == 0 + with _up_to_date_compile() as (mock_run, _): + assert toolchain.run_compile({CONF_ESPHOME: esphome}, verbose=False) == 0 - mock_run.assert_called_once_with("build", "size", jobs=1) + assert mock_run.call_args_list == [ + call("all", verbose=False, jobs=limit, progress=True), + call("size", verbose=False, jobs=limit, extra_env=toolchain._size_env()), + ] def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: @@ -650,14 +648,8 @@ def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None: ELF from get_built_elf_path, which must stay in lockstep with the project() name in the generated CMakeLists.""" _setup_build(setup_core) - config = {CONF_ESPHOME: {}} - - with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object(toolchain, "run_idf_py", return_value=0), - patch.object(toolchain, "print_summary") as mock_summary, - ): - assert toolchain.run_compile(config, verbose=False) == 0 + with _up_to_date_compile() as (_, mock_summary): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 mock_summary.assert_called_once_with( CORE.relative_build_path("build", "esp_idf_size.json"), @@ -682,21 +674,6 @@ def test_create_elf_copy_missing_source(setup_core: Path) -> None: assert toolchain.create_elf_copy() is False -def test_run_compile_without_compile_process_limit(setup_core: Path) -> None: - """When no compile_process_limit is set, no job limit is passed to idf.py.""" - _setup_build(setup_core) - config = {CONF_ESPHOME: {}} - - with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object(toolchain, "run_idf_py", return_value=0) as mock_run, - patch.object(toolchain, "print_summary"), - ): - assert toolchain.run_compile(config, verbose=False) == 0 - - mock_run.assert_called_once_with("build", "size", jobs=None) - - def test_run_compile_writes_the_pch_checksum_before_the_build( setup_core: Path, ) -> None: @@ -704,11 +681,7 @@ def test_run_compile_writes_the_pch_checksum_before_the_build( order: list[str] = [] with ( - patch.object(toolchain, "need_reconfigure", return_value=False), - patch.object( - toolchain, "run_idf_py", side_effect=lambda *a, **k: order.append("build") - ), - patch.object(toolchain, "print_summary"), + _up_to_date_compile(lambda *a, **k: order.append("build") or 0), patch( "esphome.build_gen.espidf.write_pch_checksum", side_effect=lambda: order.append("checksum"), @@ -727,6 +700,404 @@ def test_get_core_framework_version_from_core_data(): assert toolchain._get_core_framework_version() == "5.5.4" +@contextmanager +def _fake_tools(env: dict[str, str] | None = None) -> Iterator: + """Stub the IDF env and tool lookup; yield the run_build_tool mock.""" + with ( + patch.object( + toolchain, + "_get_idf_env", + return_value={"PATH": "/bin", "IDF_CCACHE_ENABLE": "0", **(env or {})}, + ), + patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "run_build_tool", return_value=0) as mock_run, + ): + yield mock_run + + +def test_run_reconfigure_cmake_argv_matches_idf_py(setup_core: Path) -> None: + """The configure is the command idf.py runs. + + IDF 5.5.5 tools/idf_py_actions/tools.py ensure_build_directory: cmake + -G Ninja -DPYTHON_DEPS_CHECKED=1 -DPYTHON= -DESP_PLATFORM=1, + then the -D entries (SDKCONFIG, CCACHE_ENABLE), then the real project + path, in the build dir. IDF 6.1 adds -B , the same as the cwd. + """ + _setup_build(setup_core) + sdkconfig = CORE.relative_build_path("sdkconfig.test") + sdkconfig.parent.mkdir(parents=True) + sdkconfig.write_text("") + project = os.path.realpath(CORE.build_path) + + with _fake_tools() as mock_run: + assert toolchain.run_reconfigure() == 0 + + assert mock_run.call_args.args[0] == [ + "/tools/cmake", + "-G", + "Ninja", + "-DPYTHON_DEPS_CHECKED=1", + "-DPYTHON=/tools/python", + "-DESP_PLATFORM=1", + f"-DSDKCONFIG={sdkconfig}", + "-DCCACHE_ENABLE=0", + project, + ] + kwargs = mock_run.call_args.kwargs + assert kwargs["cwd"] == Path(project) / "build" + assert kwargs["cwd"].is_dir() + assert kwargs["env"]["CLICOLOR_FORCE"] == "1" + assert kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES + + +@pytest.mark.parametrize(("ccache", "expected"), [("1", "True"), ("0", "False")]) +def test_run_reconfigure_cmake_argv_matches_idf6_py( + setup_core: Path, ccache: str, expected: str +) -> None: + """IDF 6 idf.py adds -B and formats CCACHE_ENABLE as True/False. + + IDF 6.1 tools/idf_py_actions/tools.py ensure_build_directory. + """ + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 1, 0) + build_dir = Path(os.path.realpath(CORE.build_path)) / "build" + + with _fake_tools({"IDF_CCACHE_ENABLE": ccache}) as mock_run: + assert toolchain.run_reconfigure() == 0 + + assert mock_run.call_args.args[0] == [ + "/tools/cmake", + "-G", + "Ninja", + "-B", + str(build_dir), + "-DPYTHON_DEPS_CHECKED=1", + "-DPYTHON=/tools/python", + "-DESP_PLATFORM=1", + f"-DCCACHE_ENABLE={expected}", + str(build_dir.parent), + ] + + +def test_run_reconfigure_without_sdkconfig_or_filter(setup_core: Path) -> None: + """No sdkconfig file means no SDKCONFIG entry; -v shows every line.""" + _setup_build(setup_core) + with _fake_tools() as mock_run: + assert toolchain.run_reconfigure(verbose=True) == 0 + cmd = mock_run.call_args.args[0] + assert not any(arg.startswith("-DSDKCONFIG=") for arg in cmd) + assert cmd[-2] == "-DCCACHE_ENABLE=0" + assert mock_run.call_args.kwargs["filter_lines"] is None + + +def test_run_reconfigure_failure_removes_cmakecache( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Like idf.py, a failed configure must not leave a cache that looks valid.""" + _setup_build(setup_core) + cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt" + cache.parent.mkdir(parents=True) + cache.write_text("") + with ( + _fake_tools() as mock_run, + patch.object(toolchain, "_print_hints") as mock_hints, + ): + mock_run.return_value = 4 + assert toolchain.run_reconfigure() == 4 + assert not cache.exists() + assert "CMake configure failed with exit code 4" in caplog.text + log_path = mock_run.call_args.kwargs["log_path"] + assert log_path == cache.parent / "log" / "cmake_output.log" + mock_hints.assert_called_once_with(log_path) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("1", "1"), + ("on", "1"), + (" Yes ", "1"), + ("0", "0"), + ("", "0"), + ("disable", "0"), + ], +) +def test_cache_entries_ccache_follows_click_booleans( + setup_core: Path, value: str, expected: str +) -> None: + """IDF_CCACHE_ENABLE maps to CCACHE_ENABLE the way idf.py's click flag does.""" + _setup_build(setup_core) + with _fake_tools({"IDF_CCACHE_ENABLE": value}): + assert toolchain._cache_entries() == {"CCACHE_ENABLE": expected} + + +def test_parse_cmakecache(tmp_path: Path) -> None: + cache = tmp_path / "CMakeCache.txt" + cache.write_text( + "# comment\n" + "// help text\n" + "SDKCONFIG:UNINITIALIZED=/a/sdkconfig.test\n" + "CCACHE_ENABLE:UNINITIALIZED=1\n" + "FLAGS:STRING=-DX=1\n" + "\n" + ) + assert toolchain._parse_cmakecache(cache) == { + "SDKCONFIG": "/a/sdkconfig.test", + "CCACHE_ENABLE": "1", + "FLAGS": "-DX=1", + } + + +# The constant -D entries every configure writes (see _configure_defines). +_CONFIGURED = ( + "PYTHON_DEPS_CHECKED:UNINITIALIZED=1\n" + "PYTHON:UNINITIALIZED=/tools/python\n" + "ESP_PLATFORM:UNINITIALIZED=1\n" +) + + +@pytest.mark.parametrize( + ("cache_text", "expected"), + [ + (None, True), + (_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=0\n", False), + (_CONFIGURED + "CCACHE_ENABLE:UNINITIALIZED=1\n", True), + ( + _CONFIGURED.replace("/tools/python", "/old/python") + + "CCACHE_ENABLE:UNINITIALIZED=0\n", + True, + ), + (_CONFIGURED, True), + ], + ids=["no_cache", "same", "changed", "python_moved", "missing"], +) +def test_cache_entries_changed( + setup_core: Path, cache_text: str | None, expected: bool +) -> None: + """Mirrors idf.py's _new_cmakecache_entries, plus the cached PYTHON.""" + _setup_build(setup_core) + if cache_text is not None: + cache = Path(os.path.realpath(CORE.build_path)) / "build" / "CMakeCache.txt" + cache.parent.mkdir(parents=True) + cache.write_text(cache_text) + with _fake_tools(): + assert toolchain._cache_entries_changed() is expected + + +@pytest.mark.parametrize( + ("version", "size_ng"), [(cv.Version(5, 5, 5), True), (cv.Version(6, 1, 0), False)] +) +def test_size_env(setup_core: Path, version: cv.Version, size_ng: bool) -> None: + """IDF 5.x idf.py sets ESP_IDF_SIZE_NG; 6.x dropped it (core_ext.py size_target).""" + CORE.data[KEY_ESP32] = {KEY_IDF_VERSION: version} + env = toolchain._size_env() + assert env["ESP_IDF_SIZE_FORCE_TERMINAL"] == "1" + assert env["SIZE_OUTPUT_FORMAT"] == "default" + assert ("ESP_IDF_SIZE_NG" in env) is size_ng + + +def test_run_ninja_matches_idf_py_run_target(setup_core: Path) -> None: + """The command is ninja [-j N] [-v] in the build dir.""" + _setup_build(setup_core) + with _fake_tools() as mock_run: + assert ( + toolchain._run_ninja( + "size", verbose=True, jobs=2, progress=True, extra_env={"A": "b"} + ) + == 0 + ) + assert mock_run.call_args.args[0] == ["/tools/ninja", "-j", "2", "-v", "size"] + kwargs = mock_run.call_args.kwargs + assert kwargs["cwd"] == Path(os.path.realpath(CORE.build_path)) / "build" + assert kwargs["env"]["CLICOLOR_FORCE"] == "1" + assert kwargs["env"]["A"] == "b" + # -v shows every line as it comes, as idf.py does + assert kwargs["filter_lines"] is None + assert kwargs["progress"] is False + + +def test_run_ninja_filters_and_reports_failure( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + _setup_build(setup_core) + with ( + _fake_tools() as mock_run, + patch.object(toolchain, "_print_hints") as mock_hints, + ): + mock_run.return_value = 1 + assert toolchain._run_ninja("all", verbose=False, jobs=None, progress=True) == 1 + log_path = mock_run.call_args.kwargs["log_path"] + assert log_path.name == "ninja_all_output.log" + mock_hints.assert_called_once_with(log_path) + assert mock_run.call_args.args[0] == ["/tools/ninja", "all"] + assert mock_run.call_args.kwargs["filter_lines"] is toolchain.FILTER_IDF_LINES + assert mock_run.call_args.kwargs["progress"] is True + assert "ninja all failed with exit code 1" in caplog.text + + +@pytest.mark.parametrize("reconfigure_rc", [0, 5]) +def test_run_compile_reconfigures_when_cache_entries_change( + setup_core: Path, reconfigure_rc: int +) -> None: + """A changed -D entry (ccache toggled) reconfigures, as idf.py build did.""" + _setup_build(setup_core) + with ( + patch.object(toolchain, "need_reconfigure", return_value=False), + patch.object(toolchain, "_cache_entries_changed", return_value=True), + patch.object( + toolchain, "run_reconfigure", return_value=reconfigure_rc + ) as mock_reconfigure, + patch.object(toolchain, "_run_ninja", return_value=0) as mock_ninja, + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == reconfigure_rc + mock_reconfigure.assert_called_once_with(True) + assert mock_ninja.called is (reconfigure_rc == 0) + + +@pytest.mark.parametrize("failing", ["all", "size"]) +def test_run_compile_stops_on_ninja_failure(setup_core: Path, failing: str) -> None: + """A failed build skips size; either failure skips the summary.""" + _setup_build(setup_core) + with _up_to_date_compile(lambda target, **kw: 7 if target == failing else 0) as ( + mock_ninja, + mock_summary, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 7 + targets = [c.args[0] for c in mock_ninja.call_args_list] + assert targets == (["all"] if failing == "all" else ["all", "size"]) + mock_summary.assert_not_called() + + +@pytest.mark.parametrize("memory_ld_rc", [0, 3]) +def test_run_compile_testing_mode_builds_memory_ld_first( + setup_core: Path, memory_ld_rc: int +) -> None: + """Testing mode builds and patches memory.ld before the main build.""" + _setup_build(setup_core) + CORE.testing_mode = True + targets: list[str] = [] + + def record(target: str, **kwargs: object) -> int: + targets.append(target) + return memory_ld_rc if target.endswith("memory.ld") else 0 + + with ( + _up_to_date_compile(record), + patch.object(toolchain, "_patch_memory_segments") as mock_patch, + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == memory_ld_rc + memory_ld = str(Path("esp-idf", "esp_system", "ld", "memory.ld")) + if memory_ld_rc: + assert targets == [memory_ld] + mock_patch.assert_not_called() + else: + assert targets == [memory_ld, "all", "size"] + mock_patch.assert_called_once() + + +@pytest.mark.parametrize("version", [cv.Version(5, 5, 5), cv.Version(6, 1, 0)]) +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({}, {"CLICOLOR_FORCE": "1", "FORCE_COLOR": "1"}), + ({"CLICOLOR_FORCE": "0"}, {"CLICOLOR_FORCE": "0", "FORCE_COLOR": "1"}), + ({"NO_COLOR": "1"}, {}), + ], + ids=["default", "user-value", "no-color"], +) +def test_tool_env_colors( + setup_core: Path, version: cv.Version, env: dict[str, str], expected: dict[str, str] +) -> None: + """IDF 6 RunTool.__call__ color handling, used for every IDF version.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = version + with _fake_tools(env): + result = toolchain._tool_env() + colors = {k: v for k, v in result.items() if k in ("CLICOLOR_FORCE", "FORCE_COLOR")} + assert colors == expected + + +@pytest.mark.parametrize( + ("limit", "env_value", "expected"), + [ + (2, "8", 2), + (None, "8", 8), + (None, "", None), + (None, None, None), + ], + ids=["limit-wins", "env", "empty-env", "unset"], +) +def test_build_jobs_honors_idf_py_build_jobs( + monkeypatch: pytest.MonkeyPatch, + limit: int | None, + env_value: str | None, + expected: int | None, +) -> None: + """compile_process_limit first, else IDF_PY_BUILD_JOBS as idf.py read it.""" + if env_value is None: + monkeypatch.delenv("IDF_PY_BUILD_JOBS", raising=False) + else: + monkeypatch.setenv("IDF_PY_BUILD_JOBS", env_value) + esphome = {} if limit is None else {CONF_COMPILE_PROCESS_LIMIT: limit} + assert toolchain._build_jobs({CONF_ESPHOME: esphome}) == expected + + +@pytest.mark.parametrize("value", ["0", "-1", "many"]) +def test_build_jobs_rejects_invalid_idf_py_build_jobs( + monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + """Like idf.py, a value that is not a positive integer is an error.""" + monkeypatch.setenv("IDF_PY_BUILD_JOBS", value) + with pytest.raises(EsphomeError, match="positive integer"): + toolchain._build_jobs({CONF_ESPHOME: {}}) + + +@contextmanager +def _hint_env(tmp_path: Path, **run_kwargs: object) -> Iterator: + with ( + patch.object(toolchain, "_get_idf_tool", return_value="/py"), + patch.object(toolchain, "_get_idf_path", return_value=tmp_path / "idf"), + patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain.subprocess, "run", **run_kwargs) as mock_run, + ): + yield mock_run + + +def test_print_hints_shows_idf_advice( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The hint step runs IDF's own generate_hints on the failed tool's log.""" + log = tmp_path / "ninja_all_output.log" + done = subprocess.CompletedProcess([], 0, "HINT: the binary is too big\n", "") + with _hint_env(tmp_path, return_value=done) as mock_run: + toolchain._print_hints(log) + cmd = mock_run.call_args.args[0] + assert cmd[0] == "/py" + assert "generate_hints" in cmd[2] + assert cmd[3:] == [str(tmp_path / "idf" / "tools"), str(log)] + assert "HINT: the binary is too big" in caplog.text + + +@pytest.mark.parametrize( + "run_kwargs", + [ + {"return_value": subprocess.CompletedProcess([], 1, "", "ImportError")}, + {"return_value": subprocess.CompletedProcess([], 0, " \n", "")}, + {"side_effect": subprocess.TimeoutExpired("py", 60)}, + {"side_effect": OSError("gone")}, + ], + ids=["script-fails", "no-hints", "timeout", "oserror"], +) +def test_print_hints_never_fails_the_build( + tmp_path: Path, caplog: pytest.LogCaptureFixture, run_kwargs: dict +) -> None: + """No hints is fine; the hint step must not raise or add warnings.""" + with caplog.at_level("WARNING"), _hint_env(tmp_path, **run_kwargs): + toolchain._print_hints(tmp_path / "log") + assert caplog.records == [] + + def test_get_cmake_cache_value_reads_the_configured_cache(setup_core: Path) -> None: _setup_build(setup_core) output = "CMAKE_OBJDUMP:FILEPATH=/tools/objdump\nESPHOME_PCH:BOOL=OFF\n" From a43d3382b537d3146471c0cb531b1e1305fa00fe Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:58:12 +0200 Subject: [PATCH 720/759] [socket] Let Zephyr clang-tidy see the BSD socket implementation (#19901) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 115bae96c6e..bec97fc82ca 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -623,6 +623,7 @@ #define USE_NRF52_REG0_VOUT 5 #define USE_NRF52_UICR_ERASE #define USE_OTA_ROLLBACK +#define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 #define USE_ZIGBEE From 46be513a39cb4bddd93ac5f034821fb916eec792 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Sep 2026 21:34:38 +0200 Subject: [PATCH 721/759] [core] Create the PlatformIO HTTP cache dir before the first registry lookup (#19906) --- esphome/platformio/library.py | 5 +++++ tests/unit_tests/test_platformio_library.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index af2010711f6..e551d8f1c04 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,8 +620,13 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin + from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient + # PlatformIO creates its HTTP cache dir without exist_ok, so two builds + # making their first registry lookup at once race on it + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: self.pkg_type = "library" diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index dba8977f1a4..658c7211ab4 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -247,6 +247,20 @@ def test_make_registry_client_skips_private_package_probe(monkeypatch): assert RegistryClient.__dict__["allowed_private_packages"] is pio_probe +def test_make_registry_client_creates_http_cache_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The HTTP cache dir exists before PlatformIO's non-exist_ok makedirs runs.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + + lib._make_registry_client() + assert (tmp_path / "http").is_dir() + # A second client (another build) must not trip over the existing dir + lib._make_registry_client() + + def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None: """Stub the registry lookup so tests never touch the network.""" monkeypatch.setattr( From fc270cb90f897d3ed29cb8f9fa1c7e0fc5fdf49e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:48:15 +0200 Subject: [PATCH 722/759] Bump platformdirs from 4.11.14 to 4.12.0 (#19904) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b62cd712a70..3d5dd8d5baa 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.14 # native esp-idf toolchain global cache dir +platformdirs==4.12.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 6b465443bc4b62ff0d5444a735c1c26856e9cc56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:48:38 +0200 Subject: [PATCH 723/759] Bump filelock from 4.0.3 to 4.0.4 (#19905) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3d5dd8d5baa..2a0b7b31f47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 662c7db2223f8190a292619fd51d247769a403d1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 29 Sep 2026 15:00:23 -0500 Subject: [PATCH 724/759] [nfc] Pass tags to triggers by reference and keep the listener list in static storage (#19902) Co-authored-by: Claude Fable 5.1 --- esphome/components/nfc/__init__.py | 3 ++- esphome/components/nfc/automation.h | 3 ++- esphome/components/nfc/binary_sensor/__init__.py | 6 ++++-- .../nfc/binary_sensor/nfc_binary_sensor.cpp | 5 +---- .../nfc/binary_sensor/nfc_binary_sensor.h | 5 +---- esphome/components/nfc/nfc.h | 4 +++- esphome/components/nfc/nfc_helpers.cpp | 8 ++++---- esphome/components/nfc/nfc_helpers.h | 4 ++-- esphome/components/nfc/nfc_tag.h | 7 ++++--- esphome/components/pn532/__init__.py | 4 ++-- esphome/components/pn71xx/__init__.py | 4 ++-- esphome/components/pn71xx/pn71xx.cpp | 4 ++++ esphome/core/defines.h | 1 + tests/components/pn532_spi/common.yaml | 9 +++++++++ tests/components/pn7160_spi/common.yaml | 15 +++++++++++++-- 15 files changed, 54 insertions(+), 28 deletions(-) diff --git a/esphome/components/nfc/__init__.py b/esphome/components/nfc/__init__.py index eea1a47b24b..6afc2eca092 100644 --- a/esphome/components/nfc/__init__.py +++ b/esphome/components/nfc/__init__.py @@ -7,7 +7,8 @@ nfc_ns = cg.esphome_ns.namespace("nfc") Nfcc = nfc_ns.class_("Nfcc") NfcTag = nfc_ns.class_("NfcTag") +NfcTagConstRef = NfcTag.operator("ref").operator("const") NfcTagListener = nfc_ns.class_("NfcTagListener") NfcOnTagTrigger = nfc_ns.class_( - "NfcOnTagTrigger", automation.Trigger.template(cg.std_string, NfcTag) + "NfcOnTagTrigger", automation.Trigger.template(cg.std_string, NfcTagConstRef) ) diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index ec3a979b647..6e03430d6fa 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,8 @@ namespace esphome::nfc { -class NfcOnTagTrigger final : public Trigger { +/// Fires with the formatted UID and the tag itself; the tag is passed by reference so no copy is made per trigger +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/__init__.py b/esphome/components/nfc/binary_sensor/__init__.py index e9747d7a142..2fde6804da9 100644 --- a/esphome/components/nfc/binary_sensor/__init__.py +++ b/esphome/components/nfc/binary_sensor/__init__.py @@ -20,7 +20,6 @@ NfcTagBinarySensor = nfc_ns.class_( binary_sensor.BinarySensor, cg.Component, NfcTagListener, - cg.Parented.template(Nfcc), ) @@ -59,12 +58,15 @@ CONFIG_SCHEMA = cv.All( ) +_request_listener_slot = cg.slot_counter("NFC_TAG_LISTENER_COUNT") + + 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_NFCC_ID]) hub = await cg.get_variable(config[CONF_NFCC_ID]) + _request_listener_slot(str(hub)) cg.add(hub.register_listener(var)) if CONF_NDEF_CONTAINS in config: cg.add(var.set_ndef_match_string(config[CONF_NDEF_CONTAINS])) diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp index 6e8162fc912..995255d1e45 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -7,10 +7,7 @@ namespace esphome::nfc { static const char *const TAG = "nfc.binary_sensor"; -void NfcTagBinarySensor::setup() { - this->parent_->register_listener(this); - this->publish_initial_state(false); -} +void NfcTagBinarySensor::setup() { this->publish_initial_state(false); } void NfcTagBinarySensor::dump_config() { std::string match_str = "name"; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index 6354e169670..f5def158348 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,7 @@ namespace esphome::nfc { -class NfcTagBinarySensor final : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, public Component, public NfcTagListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 603cb8d3148..e0f73e934ca 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -83,10 +83,12 @@ class NfcTagListener { class Nfcc { public: +#ifdef NFC_TAG_LISTENER_COUNT void register_listener(NfcTagListener *listener) { this->tag_listeners_.push_back(listener); } protected: - std::vector tag_listeners_; + StaticVector tag_listeners_; +#endif }; } // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_helpers.cpp b/esphome/components/nfc/nfc_helpers.cpp index 6c8a5b626d5..82f4591bdf4 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -4,14 +4,14 @@ namespace esphome::nfc { static const char *const TAG = "nfc.helpers"; -bool has_ha_tag_ndef(NfcTag &tag) { return !get_ha_tag_ndef(tag).empty(); } +bool has_ha_tag_ndef(const NfcTag &tag) { return !get_ha_tag_ndef(tag).empty(); } -std::string get_ha_tag_ndef(NfcTag &tag) { +std::string get_ha_tag_ndef(const NfcTag &tag) { if (!tag.has_ndef_message()) { return std::string(); } - auto message = tag.get_ndef_message(); - auto records = message->get_records(); + const auto &message = tag.get_ndef_message(); + const auto &records = message->get_records(); for (const auto &record : records) { std::string payload = record->get_payload(); size_t pos = payload.find(HA_TAG_ID_PREFIX); diff --git a/esphome/components/nfc/nfc_helpers.h b/esphome/components/nfc/nfc_helpers.h index dedc602bf19..eff6db0ea2a 100644 --- a/esphome/components/nfc/nfc_helpers.h +++ b/esphome/components/nfc/nfc_helpers.h @@ -8,8 +8,8 @@ static const char HA_TAG_ID_EXT_RECORD_TYPE[] = "android.com:pkg"; static const char HA_TAG_ID_EXT_RECORD_PAYLOAD[] = "io.homeassistant.companion.android"; static const char HA_TAG_ID_PREFIX[] = "https://www.home-assistant.io/tag/"; -std::string get_ha_tag_ndef(NfcTag &tag); +std::string get_ha_tag_ndef(const NfcTag &tag); std::string get_random_ha_tag_ndef(); -bool has_ha_tag_ndef(NfcTag &tag); +bool has_ha_tag_ndef(const NfcTag &tag); } // namespace esphome::nfc diff --git a/esphome/components/nfc/nfc_tag.h b/esphome/components/nfc/nfc_tag.h index 6cc1a00c627..a20c435e21f 100644 --- a/esphome/components/nfc/nfc_tag.h +++ b/esphome/components/nfc/nfc_tag.h @@ -42,9 +42,10 @@ class NfcTag { } NfcTagUid &get_uid() { return this->uid_; }; - const std::string &get_tag_type() { return this->tag_type_; }; - bool has_ndef_message() { return this->ndef_message_ != nullptr; }; - const std::shared_ptr &get_ndef_message() { return this->ndef_message_; }; + const NfcTagUid &get_uid() const { return this->uid_; }; + const std::string &get_tag_type() const { return this->tag_type_; }; + bool has_ndef_message() const { return this->ndef_message_ != nullptr; }; + const std::shared_ptr &get_ndef_message() const { return this->ndef_message_; }; void set_ndef_message(std::unique_ptr ndef_message) { this->ndef_message_ = std::move(ndef_message); }; protected: diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index f4f16a8b890..c9118834c56 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -66,7 +66,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) for conf in config.get(CONF_ON_TAG_REMOVED, []): @@ -74,7 +74,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index a8825747d17..8472d4192c4 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -144,7 +144,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) for conf in config.get(CONF_ON_TAG_REMOVED, []): @@ -152,7 +152,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( - trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf + trigger, [(cg.std_string, "x"), (nfc.NfcTagConstRef, "tag")], conf ) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp index 3d0b46efb47..6a24acd843e 100644 --- a/esphome/components/pn71xx/pn71xx.cpp +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -569,9 +569,11 @@ void PN71xx::erase_tag_(const uint8_t tag_index) { trigger->process(this->discovered_endpoint_[tag_index].tag); } #endif +#ifdef NFC_TAG_LISTENER_COUNT for (auto *listener : this->tag_listeners_) { listener->tag_off(*this->discovered_endpoint_[tag_index].tag); } +#endif char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); // keep the remaining entries in order; selecting_endpoint_ indexes into this list @@ -908,9 +910,11 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi trigger->process(working_endpoint.tag); } #endif +#ifdef NFC_TAG_LISTENER_COUNT for (auto *listener : this->tag_listeners_) { listener->tag_on(*working_endpoint.tag); } +#endif working_endpoint.trig_called = true; break; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bec97fc82ca..3397214c9b6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -141,6 +141,7 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define NFC_TAG_LISTENER_COUNT 1 #define PN532_BINARY_SENSOR_COUNT 1 #define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN532_ON_TAG_TRIGGER_COUNT 1 diff --git a/tests/components/pn532_spi/common.yaml b/tests/components/pn532_spi/common.yaml index f9149af35fd..7af4beac6ed 100644 --- a/tests/components/pn532_spi/common.yaml +++ b/tests/components/pn532_spi/common.yaml @@ -1,6 +1,15 @@ pn532_spi: id: pn532_nfcc_spi cs_pin: ${cs_pin} + on_tag: + then: + - delay: 10ms + - lambda: |- + ESP_LOGD("test", "%s %zu %d %d", tag.get_tag_type().c_str(), tag.get_uid().size(), tag.has_ndef_message(), + nfc::has_ha_tag_ndef(tag)); + on_tag_removed: + then: + - lambda: ESP_LOGD("test", "%s", tag.get_tag_type().c_str()); binary_sensor: - platform: pn532 diff --git a/tests/components/pn7160_spi/common.yaml b/tests/components/pn7160_spi/common.yaml index 53b37b38f45..18438a2e0da 100644 --- a/tests/components/pn7160_spi/common.yaml +++ b/tests/components/pn7160_spi/common.yaml @@ -24,8 +24,19 @@ pn7160_spi: emulation_message: https://www.home-assistant.io/tag/pulse_ce tag_ttl: 1000ms on_tag: - - logger.log: "Tag" + then: + - delay: 10ms + - lambda: |- + ESP_LOGD("test", "%s %zu %d %s", tag.get_tag_type().c_str(), tag.get_uid().size(), tag.has_ndef_message(), + nfc::get_ha_tag_ndef(tag).c_str()); on_tag_removed: - - logger.log: "Tag removed" + then: + - lambda: ESP_LOGD("test", "%s", tag.get_tag_type().c_str()); on_emulated_tag_scan: - logger.log: "Tag emulated" + +binary_sensor: + - platform: nfc + nfcc_id: nfcc_pn7160_spi + name: Pulse tag + ndef_contains: pulse From 35d41adf8606883080d368633f53b5a56ba102f9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:20:13 +1000 Subject: [PATCH 725/759] [lvgl] Animation updates and debug borders option (#19420) --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/animation.h | 25 +++++++++++++ esphome/components/lvgl/animation.py | 8 +++++ esphome/components/lvgl/defines.py | 28 +++++++++++++++ esphome/components/lvgl/lv_validation.py | 6 ++-- esphome/components/lvgl/lvgl_esphome.cpp | 1 + esphome/components/lvgl/widgets/__init__.py | 19 ++++++++++ esphome/components/lvgl/widgets/lv_list.py | 2 ++ .../lvgl/config/debug_outline.yaml | 36 +++++++++++++++++++ .../lvgl/config/no_debug_outline.yaml | 35 ++++++++++++++++++ tests/component_tests/lvgl/test_animation.py | 18 ++++++++++ .../lvgl/test_debug_outline.py | 33 +++++++++++++++++ .../lvgl/test_multi_conf_validate.py | 22 +++++++++++- .../lvgl/test-debug-outline.esp32-idf.yaml | 34 ++++++++++++++++++ tests/components/lvgl/test.host.yaml | 4 +++ 15 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/config/debug_outline.yaml create mode 100644 tests/component_tests/lvgl/config/no_debug_outline.yaml create mode 100644 tests/component_tests/lvgl/test_debug_outline.py create mode 100644 tests/components/lvgl/test-debug-outline.esp32-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 382e483d0cb..3822664c948 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -238,6 +238,7 @@ def multi_conf_validate(configs: list[dict]): CONF_COLOR_DEPTH, CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, + df.CONF_DEBUG_OUTLINE, ): if base_config[item] != config[item]: raise cv.Invalid( @@ -393,6 +394,7 @@ async def to_code(configs): df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) cg.add(lvgl_static.esphome_lvgl_init()) default_group = get_default_group(config_0) + df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE] for config in configs: frac = config[CONF_BUFFER_SIZE] @@ -634,6 +636,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, + cv.Optional(df.CONF_DEBUG_OUTLINE, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 05b84926eb6..1ef22f9b91d 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -101,6 +101,31 @@ class LvAnimationTimingEaseInOut : public LvAnimationTiming { float slope_; }; +class LvAnimationTimingEaseIn : public LvAnimationTiming { + public: + LvAnimationTimingEaseIn(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float sqr = value * value; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + +class LvAnimationTimingEaseOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float inv = 1.0f - value; + const float sqr = 1.0f - inv * inv; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + template class LvAnimation : public Component { public: LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 95d45de5ea2..bab413f04d7 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -76,6 +76,14 @@ TIMING_SCHEMA = cv.maybe_simple_value( "ease_in_out", {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), + timing_class( + "ease_in", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), + timing_class( + "ease_out", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), timing_class( "gravity", { diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f601b4b57a0..fbb621800b5 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -35,6 +35,24 @@ KEY_WIDGET_MAP = "widget_map" KEY_WIDGETS_COMPLETED = "widgets_completed" KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" + +# Colours for the debug outline, in (red, green, blue) order. They are picked to stay +# distinct from each other and to show up on both light and dark backgrounds. +DEBUG_OUTLINE_COLORS = ( + (255, 0, 0), + (0, 160, 0), + (0, 0, 255), + (255, 140, 0), + (200, 0, 200), + (0, 190, 190), + (160, 100, 0), + (255, 0, 120), + (110, 110, 110), + (140, 200, 0), + (0, 110, 255), + (130, 0, 255), +) # Initial set of LVGL features that are always enabled. _INITIAL_LV_USES = frozenset( @@ -102,6 +120,15 @@ def get_options() -> dict[str, Any]: return _get_data(KEY_OPTIONS, {}) +def next_debug_outline_color() -> tuple[int, int, int]: + """Return the next debug outline colour, cycling through the palette.""" + # A one-element list so that the count can be updated in place. + count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0]) + color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)] + count[0] += 1 + return color + + def get_defines() -> dict[str, str]: return _get_data(KEY_LV_DEFINES, {}) @@ -684,6 +711,7 @@ CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" +CONF_DEBUG_OUTLINE = "debug_outline" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" CONF_DIR = "dir" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b3285afa1af..961fa4079d7 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -157,10 +157,12 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def scale_validator(value): - return cv.float_range(0.1, 10.0)(value) + return cv.float_range(0.0, 10.0)(value) -scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256)) +scale = LValidator( + scale_validator, uint32, retmapper=lambda x: int(x * 256), animatable=True +) def angle(value): diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 2c988473a93..90a770ac3b0 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -994,6 +994,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a524fe761f9..da513e05a85 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -21,6 +21,7 @@ from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( + CONF_DEBUG_OUTLINE, CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, @@ -42,12 +43,14 @@ from ..defines import ( STATES, LValidator, add_lv_use, + get_options, get_styles_used, get_theme_widget_map, get_widget_map, get_widgets_completed, join_enums, literal, + next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( @@ -191,6 +194,7 @@ class WidgetType: w = Widget.create(wid, var, self, config) apply_theme_styles(w) await set_obj_properties(w, config) + apply_debug_outline(w) await add_widgets(w, config) await self.to_code(w, config) return w @@ -270,6 +274,21 @@ def apply_theme_styles(w: "Widget") -> None: w.add_style(style, lv_state) +def apply_debug_outline(w: "Widget") -> None: + """ + When `debug_outline` is set, outline this widget in the next palette colour. + An outline is drawn outside the widget's own box, so it doesn't take up layout + space and doesn't touch the widget's own `border_*` style, unlike a border. + """ + if not get_options().get(CONF_DEBUG_OUTLINE): + return + r, g, b = next_debug_outline_color() + w.set_style("outline_width", 1) + w.set_style("outline_pad", 0) + w.set_style("outline_color", f"lv_color_make({r}, {g}, {b})") + w.set_style("outline_opa", "LV_OPA_COVER") + + class Widget: """ Represents a Widget. diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 7711e8bfe4f..5d94c2565a2 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -57,6 +57,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t from . import ( Widget, WidgetType, + apply_debug_outline, apply_theme_styles, collect_parts, get_widgets, @@ -445,6 +446,7 @@ async def _finish_dynamic_widget( await w.type.on_create(w.obj, w_conf) apply_theme_styles(w) await set_obj_properties(w, w_conf) + apply_debug_outline(w) await w.type.to_code(w, w_conf) await _wire_dynamic_triggers(w, w_conf) for child in w_conf.get(CONF_WIDGETS, ()): diff --git a/tests/component_tests/lvgl/config/debug_outline.yaml b/tests/component_tests/lvgl/config/debug_outline.yaml new file mode 100644 index 00000000000..eb8a488c139 --- /dev/null +++ b/tests/component_tests/lvgl/config/debug_outline.yaml @@ -0,0 +1,36 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + debug_outline: true + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/config/no_debug_outline.yaml b/tests/component_tests/lvgl/config/no_debug_outline.yaml new file mode 100644 index 00000000000..2ea646bcb23 --- /dev/null +++ b/tests/component_tests/lvgl/config/no_debug_outline.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py index ce9a162d993..a150f66a40e 100644 --- a/tests/component_tests/lvgl/test_animation.py +++ b/tests/component_tests/lvgl/test_animation.py @@ -191,6 +191,24 @@ class TestTimingSchema: result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5}) assert result["weight"] == pytest.approx(0.5) + def test_ease_in_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in") + assert result["type"] == "ease_in" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_in_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + + def test_ease_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_out") + assert result["type"] == "ease_out" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_out", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + def test_gravity_defaults(self) -> None: result = TIMING_SCHEMA("gravity") assert result["type"] == "gravity" diff --git a/tests/component_tests/lvgl/test_debug_outline.py b/tests/component_tests/lvgl/test_debug_outline.py new file mode 100644 index 00000000000..78eb3c077f7 --- /dev/null +++ b/tests/component_tests/lvgl/test_debug_outline.py @@ -0,0 +1,33 @@ +"""Tests for the LVGL ``debug_outline`` option code generation.""" + +from __future__ import annotations + +import re + +_OUTLINE_COLOR_RE = re.compile( + r"lv_obj_set_style_outline_color\(.+?, (lv_color_make\(.+?\))," +) +_OUTLINE_WIDTH_RE = re.compile(r"lv_obj_set_style_outline_width\(") +_OUTLINE_PAD_RE = re.compile(r"lv_obj_set_style_outline_pad\(.+?, 0,") + + +class TestDebugOutlineCodeGeneration: + """Verify that ``debug_outline`` outlines every widget in a distinct colour.""" + + def test_debug_outline_styles_every_widget( + self, generate_main, component_config_path + ): + """Four widgets are declared, so four outlines with different colours appear.""" + main_cpp = generate_main(component_config_path("debug_outline.yaml")) + assert len(_OUTLINE_WIDTH_RE.findall(main_cpp)) == 4 + assert len(_OUTLINE_PAD_RE.findall(main_cpp)) == 4 + colors = _OUTLINE_COLOR_RE.findall(main_cpp) + assert len(colors) == 4 + assert len(set(colors)) == 4 + + def test_debug_outline_default_emits_nothing( + self, generate_main, component_config_path + ): + """Without ``debug_outline`` no outline styles are generated.""" + main_cpp = generate_main(component_config_path("no_debug_outline.yaml")) + assert "lv_obj_set_style_outline_" not in main_cpp diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index b63b7618e7d..c44b40317e8 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -9,13 +9,16 @@ from esphome.components.lvgl.schemas import theme_schema from esphome.config_validation import Invalid -def _config(displays: list[str], theme: dict | None = None) -> dict: +def _config( + displays: list[str], theme: dict | None = None, debug_outline: bool = False +) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, + df.CONF_DEBUG_OUTLINE: debug_outline, } if theme is not None: config[df.CONF_THEME] = theme @@ -53,3 +56,20 @@ class TestThemeOnMultipleInstances: def test_passes_when_no_instance_has_theme(self) -> None: configs = [_config(["disp_a"]), _config(["disp_b"])] multi_conf_validate(configs) + + +class TestDebugOutlineOnMultipleInstances: + def test_raises_when_instances_differ(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=False), + ] + with pytest.raises(Invalid, match="'debug_outline' must be the same"): + multi_conf_validate(configs) + + def test_passes_when_instances_match(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=True), + ] + multi_conf_validate(configs) diff --git a/tests/components/lvgl/test-debug-outline.esp32-idf.yaml b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml new file mode 100644 index 00000000000..73418962ffd --- /dev/null +++ b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml @@ -0,0 +1,34 @@ +# Kept separate from the main lvgl-package.yaml so that enabling debug_outline +# here doesn't add its extra style calls to the memory-impact baseline that +# every other lvgl test change is measured against. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: debug_outline_display + dimensions: + width: 240 + height: 320 + data_rate: 80MHz + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + update_interval: never + +lvgl: + debug_outline: true + displays: + - debug_outline_display + widgets: + - obj: + id: debug_outline_root + widgets: + - label: + text: Title + - button: + widgets: + - label: + text: OK diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 3fa54fa3d62..22d051da8eb 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -40,6 +40,10 @@ lvgl: - round_trip - type: ease_in_out weight: 0.5 + - type: ease_in + weight: 0.3 + - type: ease_out + weight: 0.7 on_start: - logger.log: anim started on_stop: From 99c7f9e625e8ab2cff17e6ef8c5754506fdc8aab Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 29 Sep 2026 23:28:28 +0200 Subject: [PATCH 726/759] [runtime_image] Improve format logs (#19903) Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 4 ++-- .../components/runtime_image/image_format.cpp | 7 +++++++ .../components/runtime_image/image_format.h | 3 +++ .../runtime_image/runtime_image.cpp | 9 +++++---- .../runtime_image/test_format_names.cpp | 19 +++++++++++++++++++ .../runtime_image/test_mime_types.cpp | 2 +- 6 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 tests/components/runtime_image/test_format_names.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 3f2382accd1..a21849d618c 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -122,11 +122,11 @@ void OnlineImage::update() { return; } } - ESP_LOGD(TAG, "Using image format: %d", format); + ESP_LOGD(TAG, "Using image format: %s", LOG_STR_ARG(runtime_image::get_format_name(format))); // Initialize decoder with the known format if (!this->begin_decode(total_size, format)) { - ESP_LOGE(TAG, "Failed to initialize decoder for format %d", format); + ESP_LOGE(TAG, "Failed to initialize decoder for format %s", LOG_STR_ARG(runtime_image::get_format_name(format))); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/runtime_image/image_format.cpp b/esphome/components/runtime_image/image_format.cpp index 9db84904158..368c103d15f 100644 --- a/esphome/components/runtime_image/image_format.cpp +++ b/esphome/components/runtime_image/image_format.cpp @@ -1,5 +1,6 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "image_format.h" namespace esphome::runtime_image { @@ -26,6 +27,8 @@ static constexpr MimeLookup MIME_LOOKUP_TABLE[] = { #endif }; +PROGMEM_STRING_TABLE(ImageFormatStrings, "AUTO", "BMP", "JPEG", "PNG", "QOI", "UNKNOWN"); + const char *get_mime_type_for_format(ImageFormat format) { for (const auto &entry : MIME_LOOKUP_TABLE) { if (entry.format == format) { @@ -44,4 +47,8 @@ std::optional get_format_for_mime_type(const char *mime_type) { return std::nullopt; } +const LogString *get_format_name(ImageFormat format) { + return ImageFormatStrings::get_log_str(static_cast(format), ImageFormatStrings::LAST_INDEX); +} + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h index 72bd81a06ea..60dbee39bfa 100644 --- a/esphome/components/runtime_image/image_format.h +++ b/esphome/components/runtime_image/image_format.h @@ -1,6 +1,7 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::runtime_image { @@ -25,5 +26,7 @@ enum ImageFormat { const char *get_mime_type_for_format(ImageFormat format); /// Case-insensitive substring match of known media types; nullopt if none found std::optional get_format_for_mime_type(const char *mime_type); +/// Human-readable name for a format; "UNKNOWN" if not recognized +const LogString *get_format_name(ImageFormat format); } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index ef92d0d7073..5c14d398071 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -186,14 +186,15 @@ bool RuntimeImage::begin_decode(size_t expected_size, ImageFormat format) { // An idle decoder for a different format cannot be reused if (this->decoder_ != nullptr && this->decoder_->get_format() != format) { - ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), format); + ESP_LOGD(TAG, "Decoder format mismatch: current: %s, new: %s", + LOG_STR_ARG(get_format_name(this->decoder_->get_format())), LOG_STR_ARG(get_format_name(format))); this->decoder_ = nullptr; } if (!this->decoder_) { this->decoder_ = this->create_decoder_(format); if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", format); + ESP_LOGE(TAG, "Failed to create decoder for format %s", LOG_STR_ARG(get_format_name(format))); return false; } } @@ -357,7 +358,7 @@ 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_(ImageFormat format) { - ESP_LOGV(TAG, "Creating decoder for format %d", format); + ESP_LOGV(TAG, "Creating decoder for format %s", LOG_STR_ARG(get_format_name(format))); switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: @@ -379,7 +380,7 @@ std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration"); return nullptr; default: - ESP_LOGE(TAG, "Unsupported image format: %d", format); + ESP_LOGE(TAG, "Unsupported image format: %s", LOG_STR_ARG(get_format_name(format))); return nullptr; } } diff --git a/tests/components/runtime_image/test_format_names.cpp b/tests/components/runtime_image/test_format_names.cpp new file mode 100644 index 00000000000..16299a60896 --- /dev/null +++ b/tests/components/runtime_image/test_format_names.cpp @@ -0,0 +1,19 @@ +#include + +#include "esphome/components/runtime_image/image_format.h" + +namespace esphome::runtime_image::testing { + +TEST(RuntimeImageFormatNames, FormatNames) { + EXPECT_STREQ(LOG_STR_ARG(get_format_name(AUTO)), "AUTO"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(BMP)), "BMP"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(JPEG)), "JPEG"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(PNG)), "PNG"); + EXPECT_STREQ(LOG_STR_ARG(get_format_name(QOI)), "QOI"); +} + +TEST(RuntimeImageFormatNames, OutOfRangeName) { + EXPECT_STREQ(LOG_STR_ARG(get_format_name(static_cast(254))), "UNKNOWN"); +} + +} // namespace esphome::runtime_image::testing diff --git a/tests/components/runtime_image/test_mime_types.cpp b/tests/components/runtime_image/test_mime_types.cpp index 22b825cff08..5f702d36aff 100644 --- a/tests/components/runtime_image/test_mime_types.cpp +++ b/tests/components/runtime_image/test_mime_types.cpp @@ -2,7 +2,7 @@ #include -#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/runtime_image/image_format.h" namespace esphome::runtime_image::testing { From 5502f620d4c757591baa0c2bb48b4c51b4098cc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 00:10:40 +0200 Subject: [PATCH 727/759] [web_server_idf] Send events straight from the caller's buffers instead of a std::string (#19376) --- esphome/components/json/json_util.cpp | 32 +- esphome/components/json/json_util.h | 4 + esphome/components/web_server/web_server.cpp | 454 +++++++++--------- esphome/components/web_server/web_server.h | 146 +++--- .../components/web_server_idf/sse_chunk.cpp | 66 +++ esphome/components/web_server_idf/sse_chunk.h | 40 ++ .../web_server_idf/web_server_idf.cpp | 423 +++++++++------- .../web_server_idf/web_server_idf.h | 57 ++- .../web_server_idf/test_sse_chunk.cpp | 131 +++++ 9 files changed, 850 insertions(+), 503 deletions(-) create mode 100644 esphome/components/web_server_idf/sse_chunk.cpp create mode 100644 esphome/components/web_server_idf/sse_chunk.h create mode 100644 tests/components/web_server_idf/test_sse_chunk.cpp diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 1b1eefe59b3..193390e4782 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -1,4 +1,6 @@ #include "json_util.h" + +#include #include "esphome/core/log.h" // ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h @@ -68,6 +70,22 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { JsonBuilder::JsonBuilder() = default; +size_t JsonBuilder::serialize_to(char *buf, size_t cap) { + if (doc_.overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + // Same contract as serializeJson; written by hand so no "{}" literal lives in RAM on ESP8266 + size_t n = 0; + if (n < cap) + buf[n++] = '{'; + if (n < cap) + buf[n++] = '}'; + if (n < cap) + buf[n] = '\0'; + return n; + } + return serializeJson(doc_, buf, cap); +} + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING @@ -109,17 +127,7 @@ SerializationBuffer<> JsonBuilder::serialize() { constexpr size_t buf_size = SerializationBuffer<>::BUFFER_SIZE; SerializationBuffer<> result(buf_size - 1); // Max content size (reserve 1 for null) - if (doc_.overflowed()) { - ESP_LOGE(TAG, "JSON document overflow"); - auto *buf = result.data_writable_(); - buf[0] = '{'; - buf[1] = '}'; - buf[2] = '\0'; - result.set_size_(2); - return result; - } - - size_t size = serializeJson(doc_, result.data_writable_(), buf_size); + size_t size = this->serialize_to(result.data_writable_(), buf_size); if (size < buf_size) { // Fits in stack buffer - update size to actual length result.set_size_(size); @@ -134,7 +142,7 @@ SerializationBuffer<> JsonBuilder::serialize() { size_t heap_size = buf_size * 2; while (heap_size <= max_heap_size) { result.reallocate_heap_(heap_size - 1); - size = serializeJson(doc_, result.data_writable_(), heap_size); + size = this->serialize_to(result.data_writable_(), heap_size); if (size < heap_size) { result.set_size_(size); return result; diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 130e1503321..5823e993085 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -179,6 +179,10 @@ class JsonBuilder { return root_; } + /// Serialize into a caller owned buffer. Returns the length; cap or more means it did not fit + /// and buf holds a truncated, unterminated copy. An overflowed document serializes as "{}". + size_t serialize_to(char *buf, size_t cap); + /// Serialize the JSON document to a SerializationBuffer (stack-first allocation) /// Uses 512-byte stack buffer by default, falls back to heap for larger JSON SerializationBuffer<> serialize(); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index fef484cd669..74641738ec3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1,4 +1,6 @@ #include "web_server.h" + +#include #ifdef USE_WEBSERVER #include "esphome/components/json/json_util.h" #include "esphome/core/progmem.h" @@ -58,6 +60,12 @@ static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// Out of line: every GET handler ends with this +[[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { + auto data = builder.serialize(); + request->send(200, ESPHOME_F("application/json"), data.c_str()); +} + // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): // GET /{domain}/{entity_name} - main device state @@ -193,13 +201,15 @@ DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_gener void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - auto message = de.message_generator_(web_server_, de.source_); + json::JsonBuilder builder; + de.message_generator_(web_server_, de.source_, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send } else { - // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_(). + // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::drain_tail_(). // The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and // closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without // send progress. Keep that stall policy in sync when changing either side. @@ -250,7 +260,9 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * // deferred queue still not empty which means downstream event queue full, no point trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - auto message = message_generator(web_server_, source); + json::JsonBuilder builder; + message_generator(web_server_, source, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { @@ -405,10 +417,11 @@ void WebServer::loop() { } #ifdef USE_LOGGER + void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - this->events_.try_send_nodefer(message, message_len, "log", millis()); + this->events_.try_send_nodefer(message, std::min(message_len, LOG_EVENT_MAX_LEN), "log", millis()); } #endif @@ -634,21 +647,21 @@ void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlM // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_STATE); +void WebServer::sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL); +void WebServer::sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->sensor_json_((sensor::Sensor *) (source), ((sensor::Sensor *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto uom_ref = obj->get_unit_of_measurement_ref(); @@ -662,8 +675,6 @@ json::SerializationBuffer<> WebServer::sensor_json_(sensor::Sensor *obj, float v if (!uom_ref.empty()) root[ESPHOME_F("uom")] = uom_ref.c_str(); } - - return builder.serialize(); } #endif @@ -681,32 +692,30 @@ void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->text_sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->text_sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::text_sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->text_sensor_json_((text_sensor::TextSensor *) (source), - ((text_sensor::TextSensor *) (source))->state, DETAIL_STATE); +void WebServer::text_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_sensor_json_((text_sensor::TextSensor *) (source), ((text_sensor::TextSensor *) (source))->state, + DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::text_sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->text_sensor_json_((text_sensor::TextSensor *) (source), - ((text_sensor::TextSensor *) (source))->state, DETAIL_ALL); +void WebServer::text_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_sensor_json_((text_sensor::TextSensor *) (source), ((text_sensor::TextSensor *) (source))->state, + DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -742,8 +751,9 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->switch_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->switch_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } @@ -767,14 +777,13 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::switch_state_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE); +void WebServer::switch_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::switch_all_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL); +void WebServer::switch_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); @@ -782,8 +791,6 @@ json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool v root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -795,8 +802,9 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->button_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->button_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("press"))) { DEFER_ACTION(obj, obj->press()); request->send(200); @@ -808,19 +816,16 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::button_all_json_generator(WebServer *web_server, void *source) { - return web_server->button_json_((button::Button *) (source), DETAIL_ALL); +void WebServer::button_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->button_json_((button::Button *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::button_json_(button::Button *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_id(root, obj, "button", start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -838,32 +843,30 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->binary_sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->binary_sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE); +void WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL); +void WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "binary_sensor", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -881,8 +884,9 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->fan_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->fan_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -922,14 +926,13 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } request->send(404); } -json::SerializationBuffer<> WebServer::fan_state_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE); +void WebServer::fan_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::fan_all_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL); +void WebServer::fan_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); @@ -943,8 +946,6 @@ json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -962,8 +963,9 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->light_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->light_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -1003,14 +1005,13 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::light_state_json_generator(WebServer *web_server, void *source) { - return web_server->light_json_((light::LightState *) (source), DETAIL_STATE); +void WebServer::light_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->light_json_((light::LightState *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::light_all_json_generator(WebServer *web_server, void *source) { - return web_server->light_json_((light::LightState *) (source), DETAIL_ALL); +void WebServer::light_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->light_json_((light::LightState *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_value(root, obj, "light", obj->get_reported_values().is_on() ? "ON" : "OFF", start_config); @@ -1024,8 +1025,6 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1043,8 +1042,9 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->cover_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->cover_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1091,14 +1091,13 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::cover_state_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json_((cover::Cover *) (source), DETAIL_STATE); +void WebServer::cover_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->cover_json_((cover::Cover *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::cover_all_json_generator(WebServer *web_server, void *source) { - return web_server->cover_json_((cover::Cover *) (source), DETAIL_ALL); +void WebServer::cover_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->cover_json_((cover::Cover *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, @@ -1113,8 +1112,6 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail root[ESPHOME_F("assumed_state")] = obj->get_traits().get_is_assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1132,8 +1129,9 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->number_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->number_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1151,14 +1149,13 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM request->send(404); } -json::SerializationBuffer<> WebServer::number_state_json_generator(WebServer *web_server, void *source) { - return web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE); +void WebServer::number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::number_all_json_generator(WebServer *web_server, void *source) { - return web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL); +void WebServer::number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto uom_ref = obj->get_unit_of_measurement_ref(); @@ -1181,8 +1178,6 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v root[ESPHOME_F("uom")] = uom_ref.c_str(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1199,8 +1194,9 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->date_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->date_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1225,14 +1221,13 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::date_state_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE); +void WebServer::date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::date_all_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL); +void WebServer::date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD (max 10 chars + null) @@ -1242,8 +1237,6 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1260,8 +1253,9 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->time_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->time_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1285,14 +1279,13 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::time_state_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE); +void WebServer::time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::time_all_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL); +void WebServer::time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: HH:MM:SS (8 chars + null) @@ -1302,8 +1295,6 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1320,8 +1311,9 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->datetime_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->datetime_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1345,14 +1337,13 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur } request->send(404); } -json::SerializationBuffer<> WebServer::datetime_state_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE); +void WebServer::datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::datetime_all_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL); +void WebServer::datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) @@ -1363,8 +1354,6 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1382,8 +1371,9 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->text_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->text_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1403,14 +1393,14 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::text_state_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE); +void WebServer::text_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::text_all_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL); +void WebServer::text_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); // A password entity shows the mask and prefills the input with nothing, so the secret never @@ -1425,8 +1415,6 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1444,8 +1432,9 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), detail, builder); + send_json(request, builder); return; } @@ -1465,16 +1454,16 @@ void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::select_state_json_generator(WebServer *web_server, void *source) { +void WebServer::select_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *obj = (select::Select *) (source); - return web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_STATE); + web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::select_all_json_generator(WebServer *web_server, void *source) { +void WebServer::select_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *obj = (select::Select *) (source); - return web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_ALL); + web_server->select_json_(obj, obj->has_state() ? obj->current_option() : StringRef(), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); // value points to null-terminated string literals from codegen (via current_option()) @@ -1486,8 +1475,6 @@ json::SerializationBuffer<> WebServer::select_json_(select::Select *obj, StringR } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1505,8 +1492,9 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->climate_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->climate_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1547,17 +1535,16 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url } request->send(404); } -json::SerializationBuffer<> WebServer::climate_state_json_generator(WebServer *web_server, void *source) { +void WebServer::climate_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->climate_json_((climate::Climate *) (source), DETAIL_STATE); + web_server->climate_json_((climate::Climate *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::climate_all_json_generator(WebServer *web_server, void *source) { +void WebServer::climate_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->climate_json_((climate::Climate *) (source), DETAIL_ALL); + web_server->climate_json_((climate::Climate *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config) { +void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, json::JsonBuilder &builder) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - json::JsonBuilder builder; JsonObject root = builder.root(); set_json_id(root, obj, "climate", start_config); const auto traits = obj->get_traits(); @@ -1656,7 +1643,6 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("state")] = root[ESPHOME_F("target_temperature")]; } - return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif @@ -1693,8 +1679,9 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->lock_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->lock_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } @@ -1718,22 +1705,20 @@ void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::lock_state_json_generator(WebServer *web_server, void *source) { - return web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_STATE); +void WebServer::lock_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::lock_all_json_generator(WebServer *web_server, void *source) { - return web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL); +void WebServer::lock_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->lock_json_((lock::Lock *) (source), ((lock::Lock *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1751,8 +1736,9 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->valve_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->valve_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -1797,14 +1783,13 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa } request->send(404); } -json::SerializationBuffer<> WebServer::valve_state_json_generator(WebServer *web_server, void *source) { - return web_server->valve_json_((valve::Valve *) (source), DETAIL_STATE); +void WebServer::valve_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->valve_json_((valve::Valve *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::valve_all_json_generator(WebServer *web_server, void *source) { - return web_server->valve_json_((valve::Valve *) (source), DETAIL_ALL); +void WebServer::valve_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->valve_json_((valve::Valve *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, @@ -1816,8 +1801,6 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1835,8 +1818,9 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->alarm_control_panel_json_(obj, obj->get_state(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->alarm_control_panel_json_(obj, obj->get_state(), detail, builder); + send_json(request, builder); return; } @@ -1878,20 +1862,21 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques } request->send(404); } -json::SerializationBuffer<> WebServer::alarm_control_panel_state_json_generator(WebServer *web_server, void *source) { - return web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), - ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), - DETAIL_STATE); +void WebServer::alarm_control_panel_state_json_generator(WebServer *web_server, void *source, + json::JsonBuilder &builder) { + web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), + ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), + DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::alarm_control_panel_all_json_generator(WebServer *web_server, void *source) { - return web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), - ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), - DETAIL_ALL); +void WebServer::alarm_control_panel_all_json_generator(WebServer *web_server, void *source, + json::JsonBuilder &builder) { + web_server->alarm_control_panel_json_((alarm_control_panel::AlarmControlPanel *) (source), + ((alarm_control_panel::AlarmControlPanel *) (source))->get_state(), DETAIL_ALL, + builder); } -json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, - alarm_control_panel::AlarmControlPanelState value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, + alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "alarm_control_panel", @@ -1899,8 +1884,6 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1918,8 +1901,9 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->water_heater_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->water_heater_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1957,15 +1941,15 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons request->send(404); } -json::SerializationBuffer<> WebServer::water_heater_state_json_generator(WebServer *web_server, void *source) { - return web_server->water_heater_json_(static_cast(source), DETAIL_STATE); +void WebServer::water_heater_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->water_heater_json_(static_cast(source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer *web_server, void *source) { +void WebServer::water_heater_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->water_heater_json_(static_cast(source), DETAIL_ALL); + web_server->water_heater_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); const auto mode = obj->get_mode(); @@ -2012,8 +1996,6 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (traits.has_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF)) { root[ESPHOME_F("is_on")] = obj->is_on(); } - - return builder.serialize(); } #endif @@ -2026,8 +2008,9 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->infrared_json_(obj, detail); - request->send(200, ESPHOME_F("application/json"), data.c_str()); + json::JsonBuilder builder; + this->infrared_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("transmit"))) { @@ -2086,13 +2069,12 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur request->send(404); } -json::SerializationBuffer<> WebServer::infrared_all_json_generator(WebServer *web_server, void *source) { +void WebServer::infrared_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->infrared_json_(static_cast(source), DETAIL_ALL); + web_server->infrared_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "infrared", "", 0, start_config); @@ -2105,8 +2087,6 @@ json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, J if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -2119,8 +2099,9 @@ void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, c if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->radio_frequency_json_(obj, detail); - request->send(200, ESPHOME_F("application/json"), data.c_str()); + json::JsonBuilder builder; + this->radio_frequency_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("transmit"))) { @@ -2178,14 +2159,13 @@ void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, c request->send(404); } -json::SerializationBuffer<> WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source) { +void WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL); + web_server->radio_frequency_json_(static_cast(source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "radio_frequency", "", 0, start_config); @@ -2203,8 +2183,6 @@ json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::Ra if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -2224,8 +2202,9 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->event_json_(obj, StringRef(), detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->event_json_(obj, StringRef(), detail, builder); + send_json(request, builder); return; } } @@ -2234,17 +2213,17 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa static StringRef get_event_type(event::Event *event) { return event ? event->get_last_event_type() : StringRef(); } -json::SerializationBuffer<> WebServer::event_state_json_generator(WebServer *web_server, void *source) { +void WebServer::event_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *event = static_cast(source); - return web_server->event_json_(event, get_event_type(event), DETAIL_STATE); + web_server->event_json_(event, get_event_type(event), DETAIL_STATE, builder); } // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson -json::SerializationBuffer<> WebServer::event_all_json_generator(WebServer *web_server, void *source) { +void WebServer::event_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { auto *event = static_cast(source); - return web_server->event_json_(event, get_event_type(event), DETAIL_ALL); + web_server->event_json_(event, get_event_type(event), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_id(root, obj, "event", start_config); @@ -2260,8 +2239,6 @@ json::SerializationBuffer<> WebServer::event_json_(event::Event *obj, StringRef root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) #endif @@ -2278,8 +2255,9 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->update_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->update_json_(obj, detail, builder); + send_json(request, builder); return; } @@ -2294,17 +2272,16 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *web_server, void *source) { +void WebServer::update_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { +void WebServer::update_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { +void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - json::JsonBuilder builder; JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), @@ -2321,7 +2298,6 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J this->add_sorting_info_(root, obj); } - return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3236ccfd3c8..143e9a15eae 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -37,6 +37,10 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { +// ESP-IDF cuts a log event here: nothing a browser log view needs is longer, and it bounds the +// tail a stalled client keeps. The Arduino backend takes a C string and sends the whole line. +constexpr size_t LOG_EVENT_MAX_LEN = 512; + // Parameter names live in flash on ESP8266 using ParamNameType = ProgmemStr; @@ -101,7 +105,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE }; can be forgotten. */ #if !defined(USE_ESP32) && defined(USE_ARDUINO) -using message_generator_t = json::SerializationBuffer<>(WebServer *, void *); +using message_generator_t = void(WebServer *, void *, json::JsonBuilder &); class DeferredUpdateEventSourceList; class DeferredUpdateEventSource final : public AsyncEventSource { @@ -296,8 +300,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a sensor request under '/sensor/'. void handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> sensor_all_json_generator(WebServer *web_server, void *source); + static void sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_SWITCH @@ -306,8 +310,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a switch request under '/switch//'. void handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> switch_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> switch_all_json_generator(WebServer *web_server, void *source); + static void switch_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void switch_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_BUTTON @@ -315,7 +319,7 @@ class WebServer final : public Component, public AsyncWebHandler { void handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match); // Buttons are stateless, so there is no button_state_json_generator - static json::SerializationBuffer<> button_all_json_generator(WebServer *web_server, void *source); + static void button_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_BINARY_SENSOR @@ -324,8 +328,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a binary sensor request under '/binary_sensor/'. void handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> binary_sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> binary_sensor_all_json_generator(WebServer *web_server, void *source); + static void binary_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void binary_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_FAN @@ -334,8 +338,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a fan request under '/fan//'. void handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> fan_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> fan_all_json_generator(WebServer *web_server, void *source); + static void fan_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void fan_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_LIGHT @@ -344,8 +348,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a light request under '/light//'. void handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> light_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> light_all_json_generator(WebServer *web_server, void *source); + static void light_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void light_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_TEXT_SENSOR @@ -354,8 +358,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a text sensor request under '/text_sensor/'. void handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> text_sensor_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> text_sensor_all_json_generator(WebServer *web_server, void *source); + static void text_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void text_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_COVER @@ -364,8 +368,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a cover request under '/cover//'. void handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> cover_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> cover_all_json_generator(WebServer *web_server, void *source); + static void cover_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void cover_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER @@ -373,8 +377,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a number request under '/number/'. void handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> number_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> number_all_json_generator(WebServer *web_server, void *source); + static void number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE @@ -382,8 +386,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a date request under '/date/'. void handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> date_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> date_all_json_generator(WebServer *web_server, void *source); + static void date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_TIME @@ -391,8 +395,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a time request under '/time/'. void handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> time_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> time_all_json_generator(WebServer *web_server, void *source); + static void time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATETIME @@ -400,8 +404,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a datetime request under '/datetime/'. void handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> datetime_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> datetime_all_json_generator(WebServer *web_server, void *source); + static void datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_TEXT @@ -409,8 +413,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a text input request under '/text/'. void handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> text_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> text_all_json_generator(WebServer *web_server, void *source); + static void text_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void text_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_SELECT @@ -418,8 +422,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a select request under '/select/'. void handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> select_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> select_all_json_generator(WebServer *web_server, void *source); + static void select_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void select_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_CLIMATE @@ -427,8 +431,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a climate request under '/climate/'. void handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> climate_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> climate_all_json_generator(WebServer *web_server, void *source); + static void climate_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void climate_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_LOCK @@ -437,8 +441,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a lock request under '/lock//'. void handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> lock_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> lock_all_json_generator(WebServer *web_server, void *source); + static void lock_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void lock_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_VALVE @@ -447,8 +451,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a valve request under '/valve//'. void handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> valve_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> valve_all_json_generator(WebServer *web_server, void *source); + static void valve_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void valve_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -457,8 +461,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a alarm_control_panel request under '/alarm_control_panel/'. void handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> alarm_control_panel_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> alarm_control_panel_all_json_generator(WebServer *web_server, void *source); + static void alarm_control_panel_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void alarm_control_panel_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_WATER_HEATER @@ -467,21 +471,21 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a water_heater request under '/water_heater//'. void handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> water_heater_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> water_heater_all_json_generator(WebServer *web_server, void *source); + static void water_heater_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void water_heater_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_INFRARED /// Handle an infrared request under '/infrared//transmit'. void handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> infrared_all_json_generator(WebServer *web_server, void *source); + static void infrared_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_RADIO_FREQUENCY /// Handle a radio frequency request under '/radio_frequency//transmit'. void handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); + static void radio_frequency_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif #ifdef USE_MEDIA_PLAYER @@ -492,8 +496,8 @@ class WebServer final : public Component, public AsyncWebHandler { #ifdef USE_EVENT void on_event(event::Event *obj); - static json::SerializationBuffer<> event_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> event_all_json_generator(WebServer *web_server, void *source); + static void event_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void event_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); /// Handle a event request under '/event'. void handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -505,8 +509,8 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle a update request under '/update/'. void handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match); - static json::SerializationBuffer<> update_state_json_generator(WebServer *web_server, void *source); - static json::SerializationBuffer<> update_all_json_generator(WebServer *web_server, void *source); + static void update_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); + static void update_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder); #endif /// Override the web handler's canHandle method. @@ -624,77 +628,77 @@ class WebServer final : public Component, public AsyncWebHandler { private: #ifdef USE_SENSOR - json::SerializationBuffer<> sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config); + void sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_SWITCH - json::SerializationBuffer<> switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config); + void switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_BUTTON - json::SerializationBuffer<> button_json_(button::Button *obj, JsonDetail start_config); + void button_json_(button::Button *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_BINARY_SENSOR - json::SerializationBuffer<> binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, - JsonDetail start_config); + void binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_FAN - json::SerializationBuffer<> fan_json_(fan::Fan *obj, JsonDetail start_config); + void fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_LIGHT - json::SerializationBuffer<> light_json_(light::LightState *obj, JsonDetail start_config); + void light_json_(light::LightState *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_TEXT_SENSOR - json::SerializationBuffer<> text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, - JsonDetail start_config); + void text_sensor_json_(text_sensor::TextSensor *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_COVER - json::SerializationBuffer<> cover_json_(cover::Cover *obj, JsonDetail start_config); + void cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - json::SerializationBuffer<> number_json_(number::Number *obj, float value, JsonDetail start_config); + void number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE - json::SerializationBuffer<> date_json_(datetime::DateEntity *obj, JsonDetail start_config); + void date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_TIME - json::SerializationBuffer<> time_json_(datetime::TimeEntity *obj, JsonDetail start_config); + void time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATETIME - json::SerializationBuffer<> datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config); + void datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_TEXT - json::SerializationBuffer<> text_json_(text::Text *obj, const std::string &value, JsonDetail start_config); + void text_json_(text::Text *obj, const std::string &value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_SELECT - json::SerializationBuffer<> select_json_(select::Select *obj, StringRef value, JsonDetail start_config); + void select_json_(select::Select *obj, StringRef value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_CLIMATE - json::SerializationBuffer<> climate_json_(climate::Climate *obj, JsonDetail start_config); + void climate_json_(climate::Climate *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_LOCK - json::SerializationBuffer<> lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config); + void lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_VALVE - json::SerializationBuffer<> valve_json_(valve::Valve *obj, JsonDetail start_config); + void valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_ALARM_CONTROL_PANEL - json::SerializationBuffer<> alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, - alarm_control_panel::AlarmControlPanelState value, - JsonDetail start_config); + void alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel *obj, + alarm_control_panel::AlarmControlPanelState value, JsonDetail start_config, + json::JsonBuilder &builder); #endif #ifdef USE_EVENT - json::SerializationBuffer<> event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config); + void event_json_(event::Event *obj, StringRef event_type, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_WATER_HEATER - json::SerializationBuffer<> water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config); + void water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_INFRARED - json::SerializationBuffer<> infrared_json_(infrared::Infrared *obj, JsonDetail start_config); + void infrared_json_(infrared::Infrared *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_RADIO_FREQUENCY - json::SerializationBuffer<> radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config); + void radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_UPDATE - json::SerializationBuffer<> update_json_(update::UpdateEntity *obj, JsonDetail start_config); + void update_json_(update::UpdateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif }; diff --git a/esphome/components/web_server_idf/sse_chunk.cpp b/esphome/components/web_server_idf/sse_chunk.cpp new file mode 100644 index 00000000000..a312b64c03e --- /dev/null +++ b/esphome/components/web_server_idf/sse_chunk.cpp @@ -0,0 +1,66 @@ +#include "sse_chunk.h" + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::web_server_idf { + +void for_each_chunk_piece(const char *message, size_t message_len, ChunkPieceSink sink, void *ctx) { + if (message == nullptr) { + sink(ctx, CHUNK_END, CHUNK_END_LEN); + return; + } + const char *pos = message; + const char *end = message + message_len; + for (;;) { + const size_t remaining = end - pos; + const auto *n = static_cast(memchr(pos, '\n', remaining)); + // Only a \r before the next \n can end this line, so the search stops there instead of + // rescanning the rest of the message for every line + const auto *r = static_cast(memchr(pos, '\r', n != nullptr ? n - pos : remaining)); + if (n == nullptr && r == nullptr) { + sink(ctx, pos, remaining); + break; + } + const char *brk = (r != nullptr && (n == nullptr || r < n)) ? r : n; + sink(ctx, pos, brk - pos); + pos = brk + ((brk == r && brk + 1 == n) ? 2 : 1); + if (pos >= end) { + break; + } + sink(ctx, SSE_SEP, SSE_SEP_LEN); + } + sink(ctx, SSE_SUFFIX, SSE_SUFFIX_LEN); +} + +size_t build_chunk_prefix(char *buf, size_t size, const char *event, uint32_t id, uint32_t reconnect, bool with_data) { + size_t len = CHUNK_HDR_LEN; + if (reconnect) { + len = buf_append_printf(buf, size, len, "retry: %" PRIu32 "\r\n", reconnect); + } + if (id) { + len = buf_append_printf(buf, size, len, "id: %" PRIu32 "\r\n", id); + } + if (event && *event) { + len = buf_append_str(buf, size, len, "event: "); + len = buf_append_str(buf, size, len, event); + len = buf_append_str(buf, size, len, "\r\n"); + } + if (with_data) { + len = buf_append_str(buf, size, len, "data: "); + } + return len; +} + +void write_chunk_header(char *buf, size_t chunk_len) { + // Eight lowercase hex digits; the temp keeps the terminator format_hex_to writes out of buf + char digits[9]; + format_hex_to(digits, static_cast(chunk_len)); + std::memcpy(buf, digits, 8); + buf[8] = '\r'; + buf[9] = '\n'; +} + +} // namespace esphome::web_server_idf diff --git a/esphome/components/web_server_idf/sse_chunk.h b/esphome/components/web_server_idf/sse_chunk.h new file mode 100644 index 00000000000..c8a4170008a --- /dev/null +++ b/esphome/components/web_server_idf/sse_chunk.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace esphome::web_server_idf { + +// Wire framing of one Server-Sent-Events event inside the chunked /events response: +// "%08x\r\n" + fields + "data: " line ["\r\ndata: " line]... "\r\n" + "\r\n" + "\r\n" +// The chunk header and the final CRLF (the chunk terminator) do not count toward the +// chunk length; everything between them does. + +// HTTP chunk header "%08x\r\n" +constexpr size_t CHUNK_HDR_LEN = 10; +// Chunk terminator, also the only piece of a null message (no data line, no blank line) +constexpr char CHUNK_END[] = "\r\n"; +constexpr size_t CHUNK_END_LEN = sizeof(CHUNK_END) - 1; +// Between two data lines: the end of one and the prefix of the next +constexpr char SSE_SEP[] = "\r\ndata: "; +constexpr size_t SSE_SEP_LEN = sizeof(SSE_SEP) - 1; +// End of the last data line, the blank line ending the event, and the chunk terminator +constexpr char SSE_SUFFIX[] = "\r\n\r\n\r\n"; +constexpr size_t SSE_SUFFIX_LEN = sizeof(SSE_SUFFIX) - 1; + +// Receives one piece of the chunk; ctx is whatever the caller passed to for_each_chunk_piece() +using ChunkPieceSink = void (*)(void *ctx, const char *piece, size_t len); + +// Calls sink for each piece after the prefix: data lines split on \n, \r or \r\n with SSE_SEP +// between them and SSE_SUFFIX after the last (as ESPAsyncWebServer: a trailing break adds no +// empty line, an inner one is kept). A null message yields only the chunk terminator. +void for_each_chunk_piece(const char *message, size_t message_len, ChunkPieceSink sink, void *ctx); + +// Writes the chunk header placeholder, the retry/id/event lines and, with_data, the first +// "data: " into buf. Returns the prefix length; size - 1 or more means the event name did not fit. +size_t build_chunk_prefix(char *buf, size_t size, const char *event, uint32_t id, uint32_t reconnect, bool with_data); + +// Fills the header placeholder at the start of buf once the chunk length is known +void write_chunk_header(char *buf, size_t chunk_len); + +} // namespace esphome::web_server_idf diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0062556063f..837a705cd36 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,7 +1,9 @@ #ifdef USE_ESP32 +#include #include #include +#include #include #include #include @@ -15,6 +17,7 @@ #include #include "utils.h" +#include "sse_chunk.h" #include "web_server_idf.h" #ifdef USE_WEBSERVER_AUTH_DIGEST @@ -740,7 +743,8 @@ bool AsyncEventSource::loop() { ++i; } } - return !this->sessions_.empty(); + // A session still waiting for httpd to commit its context keeps the loop alive too + return !this->sessions_.empty() || this->has_pending_sessions_.load(std::memory_order_acquire); } void AsyncEventSource::adopt_pending_sessions_main_loop_() { @@ -756,6 +760,14 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; } + // httpd commits the session context only after the creating handler returns, so stay + // pending until then; httpd_req_cleanup() always commits it or calls destroy() + if (httpd_sess_get_ctx(rsp->hd_, rsp->fd_.load()) != rsp) { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + this->has_pending_sessions_.store(true, std::memory_order_release); + continue; + } this->sessions_.push_back(rsp); // Prime first so on_connect_ observes a session that has already sent its // initial ping/config/sorting_groups, matching the pre-refactor ordering. @@ -792,7 +804,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { - // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. + // Httpd task only. start_session_main_loop_() sends the greeting and starts the iterator. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -820,9 +832,14 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::start_session_main_loop_() { auto *ws = this->web_server_; - // tcp send buffer is empty on connect, so these should always go through + // The tcp send buffer is empty on connect. A refusal is a closing session or a failed tail + // allocation; nothing retries the greeting, so close and let the client reconnect. auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); + if (!this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000)) { + ESP_LOGW(TAG, "Config not sent to fd %d", this->fd_.load()); + this->request_close_(); + return; + } #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -833,8 +850,13 @@ void AsyncEventSourceResponse::start_session_main_loop_() { message = builder.serialize(); // a (very) large number of these should be able to be queued initially without defer - // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); + // since the only thing in the send buffer at this point is the initial ping/config. + // A refusal means the socket is full or closing; the remaining groups are not sent. The + // session stays up on purpose: partial grouping beats a reconnect loop on a slow link. + if (!this->try_send_nodefer(message.c_str(), message.size(), "sorting_group")) { + ESP_LOGW(TAG, "Sorting groups not sent to fd %d", this->fd_.load()); + break; + } } #endif @@ -871,8 +893,9 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { + json::JsonBuilder builder; + de.message_generator_(web_server_, de.source_, builder); + if (this->send_json_(builder)) { if (this->close_requested_ || deferred_queue_.empty()) { return; } @@ -888,8 +911,10 @@ void AsyncEventSourceResponse::request_close_() { if (!this->close_requested_) { this->close_requested_ = true; this->deferred_queue_.clear(); - this->event_buffer_.clear(); - this->event_bytes_sent_ = 0; + this->tail_.reset(); + this->tail_cap_ = 0; + this->tail_len_ = 0; + this->tail_sent_ = 0; this->next_close_attempt_ms_ = App.get_loop_component_start_time(); } @@ -940,21 +965,43 @@ void AsyncEventSourceResponse::close_session_work(void *arg) { response->close_work_queued_.store(false, std::memory_order_release); } -void AsyncEventSourceResponse::process_buffer_() { - if (this->close_requested_ || event_buffer_.empty()) { - return; +ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { + // httpd frees a session before closing its socket, so the fd may already be a new client's. + // Treated as would-block; the stall timer ends a session that never becomes ours again. + const int fd = this->fd_.load(); + if (httpd_sess_get_ctx(this->hd_, fd) != this) { + return 0; } - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 0; - return; + struct msghdr msg {}; + msg.msg_iov = iov; + msg.msg_iovlen = iovcnt; + const ssize_t sent = sendmsg(fd, &msg, MSG_DONTWAIT); + if (sent >= 0) { + return sent; } + const int err = errno; + if (err == EAGAIN || err == EWOULDBLOCK) { + return 0; + } + ESP_LOGD(TAG, "send error: errno %d", err); + this->request_close_(); + return -1; +} - size_t remaining = event_buffer_.size() - event_bytes_sent_; - int bytes_sent = - httpd_socket_send(this->hd_, this->fd_.load(), event_buffer_.c_str() + event_bytes_sent_, remaining, 0); - if (bytes_sent == HTTPD_SOCK_ERR_TIMEOUT) { - // EAGAIN/EWOULDBLOCK - socket buffer full, try again later +void AsyncEventSourceResponse::drain_tail_() { + if (this->sending_ || this->close_requested_ || this->tail_len_ == 0) { + return; + } + SendGuard guard{*this}; + + const size_t remaining = this->tail_len_ - this->tail_sent_; + struct iovec iov = {this->tail_.get() + this->tail_sent_, remaining}; + const ssize_t sent = this->send_(&iov, 1); + if (sent < 0) { + return; + } + if (sent == 0) { + // Socket buffer full, try again later // NOTE: Similar logic exists in web_server/web_server.cpp in DeferredUpdateEventSource::process_deferred_queue_(). // The IDF path is intentionally time-based and closes through HTTPD to preserve session ownership. const uint32_t now = App.get_loop_component_start_time(); @@ -968,32 +1015,62 @@ void AsyncEventSourceResponse::process_buffer_() { } return; } - if (bytes_sent == HTTPD_SOCK_ERR_FAIL) { - // Low-level asynchronous sends do not make HTTPD close the session automatically. - this->request_close_(); - return; - } - if (bytes_sent <= 0) { - // Unexpected error or zero bytes sent - ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent); - this->request_close_(); - return; - } - // Successful send - reset stall tracking this->send_failure_started_ms_ = 0; - event_bytes_sent_ += bytes_sent; - - // Log partial sends for debugging - if (event_bytes_sent_ < event_buffer_.size()) { - ESP_LOGV(TAG, "Partial send: %d/%zu bytes (total: %zu/%zu)", bytes_sent, remaining, event_bytes_sent_, - event_buffer_.size()); + this->tail_sent_ += sent; + if (this->tail_sent_ < this->tail_len_) { + ESP_LOGV(TAG, "Partial send: %zd/%zu bytes (total: %u/%u)", sent, remaining, this->tail_sent_, this->tail_len_); + return; } + // Fully sent; the storage stays for the next stall + this->tail_len_ = 0; +} - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 0; +bool AsyncEventSourceResponse::reserve_tail_(size_t len) { + if (this->tail_cap_ >= len) { + return true; } + if (len > TAIL_MAX_SIZE) { + return false; + } + // Nothing is pending while the tail grows, so free the old block first. PREFER_INTERNAL keeps + // the tail where plain new put it. + this->tail_.reset(); + this->tail_ = RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(len); + this->tail_cap_ = this->tail_ ? len : 0; + return this->tail_cap_ != 0; +} + +bool AsyncEventSourceResponse::stash_chunk_(const char *prefix, size_t prefix_len, const char *message, + size_t message_len, size_t total, size_t sent) { + // A log event of nothing but line breaks is the largest chunk; reserve_tail_() must never + // refuse it for size, only for memory + static_assert(SSE_SEP_LEN * web_server::LOG_EVENT_MAX_LEN + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN <= TAIL_MAX_SIZE, + "the log cut in web_server.h must keep a worst case log event inside the tail ceiling"); + if (!this->reserve_tail_(total)) { + if (sent != 0) { + // Part of the chunk is on the wire, so the stream is broken and the client has to go + ESP_LOGW(TAG, "Cannot buffer a %zu byte chunk, closing", total); + this->request_close_(); + } else { + this->tail_alloc_failed_(total); // nothing on the wire, the caller retries on the stall clock + } + return false; + } + uint8_t *dst = this->tail_.get(); + std::memcpy(dst, prefix, prefix_len); + dst += prefix_len; + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { + auto &out = *static_cast(ctx); + std::memcpy(out, piece, len); + out += len; + }, + &dst); + this->tail_len_ = total; + this->tail_sent_ = sent; + return true; } void AsyncEventSourceResponse::loop() { @@ -1001,7 +1078,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } - process_buffer_(); + drain_tail_(); process_deferred_queue_(); if (this->close_requested_) return; @@ -1009,142 +1086,138 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.try_advance(1); } +bool AsyncEventSourceResponse::send_json_(json::JsonBuilder &builder) { + char buf[JSON_BUF_SIZE]; + const size_t len = builder.serialize_to(buf, sizeof(buf)); + if (len < sizeof(buf)) { + return this->try_send_nodefer(buf, len, "state"); + } + + // Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized + // JSON has no raw line break, so the body is one data line. + if (!this->ready_to_send_()) { + return false; + } + { + SendGuard guard{*this}; + char prefix[PREFIX_BUF_SIZE]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true); + + // Grow the tail until the document fits. Nothing has reached the wire, so a document that + // cannot be held costs only this event, and the tail grown for it is released. + size_t json_len = 0; + size_t cap = std::max(JSON_BUF_SIZE * 2, this->tail_cap_); + for (;;) { + if (!this->reserve_tail_(cap)) { + this->tail_alloc_failed_(cap); + return false; // stays deferred, retried on a later pass + } + const size_t room = cap - prefix_len - SSE_SUFFIX_LEN; + json_len = builder.serialize_to(reinterpret_cast(this->tail_.get()) + prefix_len, room); + if (json_len < room) { + break; + } + if (cap >= TAIL_MAX_SIZE) { + ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room); + this->tail_.reset(); + this->tail_cap_ = 0; + this->send_failure_started_ms_ = 0; + return true; // would never fit, reported as sent + } + cap = std::min(cap * 2, TAIL_MAX_SIZE); + } + + const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN; + write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN); + uint8_t *dst = this->tail_.get(); + std::memcpy(dst, prefix, prefix_len); + std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN); + this->tail_len_ = total; + this->tail_sent_ = 0; + } + drain_tail_(); + return true; +} + +void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) { + // Same stall clock as a socket that stops draining, so a session cannot retry forever + const uint32_t now = App.get_loop_component_start_time(); + if (this->send_failure_started_ms_ == 0) { + this->send_failure_started_ms_ = now != 0 ? now : 1; // Reserve zero for no stall. + ESP_LOGW(TAG, "No memory for a %zu byte chunk", cap); + return; + } + if (static_cast(now - (this->send_failure_started_ms_ + SEND_STALL_TIMEOUT_MS)) >= 0) { + ESP_LOGW(TAG, "Closing EventSource after %" PRIu32 " ms without memory", now - this->send_failure_started_ms_); + this->request_close_(); + } +} + +bool AsyncEventSourceResponse::ready_to_send_() { + if (this->sending_ || this->fd_.load() == 0 || this->close_requested_) { + return false; + } + drain_tail_(); + return !this->close_requested_ && this->tail_len_ == 0; +} + bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { - if (this->fd_.load() == 0 || this->close_requested_) { + if (!this->ready_to_send_()) { return false; } + SendGuard guard{*this}; - process_buffer_(); - if (this->close_requested_ || !event_buffer_.empty()) { - // there is still pending event data to send first - return false; + // Everything after the prefix goes out straight from the caller's buffer + char prefix[PREFIX_BUF_SIZE]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr); + if (message == nullptr && prefix_len == CHUNK_HDR_LEN) { + return true; // Match ESPAsyncWebServer: nothing to send } - - // 8 spaces are standing in for the hexidecimal chunk length to print later - const char chunk_len_header[] = " " CRLF_STR; - const int chunk_len_header_len = sizeof(chunk_len_header) - 1; - - event_buffer_.append(chunk_len_header); - - // Use stack buffer for formatting numeric fields to avoid temporary string allocations - // Size: "retry: " (7) + max uint32 (10 digits) + CRLF (2) + null (1) = 20 bytes, use 32 for safety - constexpr size_t num_buf_size = 32; - char num_buf[num_buf_size]; - - if (reconnect) { - int len = snprintf(num_buf, num_buf_size, "retry: %" PRIu32 CRLF_STR, reconnect); - event_buffer_.append(num_buf, len); - } - - if (id) { - int len = snprintf(num_buf, num_buf_size, "id: %" PRIu32 CRLF_STR, id); - event_buffer_.append(num_buf, len); - } - - if (event && *event) { - event_buffer_.append("event: ", sizeof("event: ") - 1); - event_buffer_.append(event); - event_buffer_.append(CRLF_STR, CRLF_LEN); - } - - // Match ESPAsyncWebServer: null message means no data lines and no terminating blank line - if (message) { - // SSE spec requires each line of a multi-line message to have its own "data:" prefix - // Handle \n, \r, and \r\n line endings (matching ESPAsyncWebServer behavior) - - // Fast path: check if message contains any newlines at all - // Most SSE messages (JSON state updates) have no newlines - const char *first_n = static_cast(memchr(message, '\n', message_len)); - const char *first_r = static_cast(memchr(message, '\r', message_len)); - - if (first_n == nullptr && first_r == nullptr) { - // No newlines - fast path (most common case) - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message, message_len); - event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator - } else { - // Has newlines - handle multi-line message - const char *line_start = message; - const char *msg_end = message + message_len; - - // Reuse the first search results - const char *next_n = first_n; - const char *next_r = first_r; - - while (line_start <= msg_end) { - const char *line_end; - const char *next_line; - - if (next_n == nullptr && next_r == nullptr) { - // No more line breaks - output remaining text as final line - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start, msg_end - line_start); - event_buffer_.append(CRLF_STR, CRLF_LEN); - break; - } - - // Determine line ending type and next line start - if (next_n != nullptr && next_r != nullptr) { - if (next_r + 1 == next_n) { - // \r\n sequence - line_end = next_r; - next_line = next_n + 1; - } else { - // Mixed \n and \r - use whichever comes first - line_end = (next_r < next_n) ? next_r : next_n; - next_line = line_end + 1; - } - } else if (next_n != nullptr) { - // Unix LF - line_end = next_n; - next_line = next_n + 1; - } else { - // Old Mac CR - line_end = next_r; - next_line = next_r + 1; - } - - // Output this line - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start, line_end - line_start); - event_buffer_.append(CRLF_STR, CRLF_LEN); - - line_start = next_line; - - // Check if we've consumed all content - if (line_start >= msg_end) { - break; - } - - // Search for next newlines only in remaining string - next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); - next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); - } - - // Terminate message with blank line - event_buffer_.append(CRLF_STR, CRLF_LEN); - } - } - - if (event_buffer_.size() == static_cast(chunk_len_header_len)) { - // Nothing was added, reset buffer - event_buffer_.resize(0); + if (prefix_len >= PREFIX_BUF_SIZE - 1) { + // The appenders truncate silently, which would put a malformed event on the wire + ESP_LOGW(TAG, "Event name too long, dropped"); return true; } - event_buffer_.append(CRLF_STR, CRLF_LEN); + // Gather list: the prefix, then the data lines and their separators from the message + struct Gather { + struct iovec iov[MAX_SEND_IOV]; // left uninitialized on purpose + int iovcnt{1}; + size_t total{0}; + bool fits{true}; + } g; + g.total = prefix_len; + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { + auto &g = *static_cast(ctx); + g.total += len; + if (len == 0) { + return; + } + if (g.iovcnt == MAX_SEND_IOV) { + g.fits = false; + return; + } + g.iov[g.iovcnt++] = {const_cast(piece), len}; + }, + &g); + // The header and the terminator are not part of the chunk length + write_chunk_header(prefix, g.total - CHUNK_HDR_LEN - CHUNK_END_LEN); + g.iov[0] = {prefix, prefix_len}; - // chunk length header itself and the final chunk terminating CRLF are not counted as part of the chunk - int chunk_len = event_buffer_.size() - CRLF_LEN - chunk_len_header_len; - char chunk_len_str[9]; - snprintf(chunk_len_str, 9, "%08x", chunk_len); - std::memcpy(&event_buffer_[0], chunk_len_str, 8); - - event_bytes_sent_ = 0; - process_buffer_(); - - return true; + // A message with more lines than the list holds skips straight to the tail + const ssize_t sent = g.fits ? this->send_(g.iov, g.iovcnt) : 0; + if (sent < 0) { + return false; + } + if (static_cast(sent) == g.total) { + this->send_failure_started_ms_ = 0; // progress, whichever stall clock was running + return true; + } + // The caller's buffers do not outlive this call, so keep the chunk and continue from loop() + return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent); } void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *event_type, @@ -1165,20 +1238,22 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e ESP_LOGE(TAG, "Can't defer non-state event"); } - process_buffer_(); + drain_tail_(); process_deferred_queue_(); if (this->close_requested_) { return; } - if (!event_buffer_.empty() || !deferred_queue_.empty()) { + if (this->tail_len_ != 0 || !deferred_queue_.empty()) { // outgoing event buffer or deferred queue still not empty which means downstream tcp send buffer full, no point // trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { + json::JsonBuilder builder; + message_generator(web_server_, source, builder); + // A send error closes the session and clears the queue; nothing is queued after that + if (!this->send_json_(builder) && !this->close_requested_) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 7894206102f..0f0977229c6 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -18,8 +18,11 @@ #ifdef USE_WEBSERVER #include "esphome/components/json/json_util.h" #include "esphome/components/web_server/list_entities.h" +#include "sse_chunk.h" #endif +struct iovec; // NOLINT(readability-identifier-naming) - forward decl of lwip's gather list entry + namespace esphome { #ifdef USE_WEBSERVER namespace web_server { @@ -255,7 +258,7 @@ class AsyncWebHandler { class AsyncEventSource; class AsyncEventSourceResponse; -using message_generator_t = json::SerializationBuffer<>(esphome::web_server::WebServer *, void *); +using message_generator_t = void(esphome::web_server::WebServer *, void *, json::JsonBuilder &); /* This class holds a pointer to the source component that wants to publish a state event, and a pointer to a function @@ -300,8 +303,30 @@ class AsyncEventSourceResponse { void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); - void process_buffer_(); + // A new chunk may go out: not re-entered from a log line, session alive, tail empty + bool ready_to_send_(); + // Non-blocking gather write. Returns bytes written, 0 on would-block or a socket that is not + // ours, -1 after requesting the close on any other error. + ssize_t send_(struct iovec *iov, int iovcnt); + // Push what is left of the chunk in tail_ to the socket; owns the stall timer. + void drain_tail_(); + // Grow tail_ to hold len bytes, kept at its high-water mark. False on OOM. + bool reserve_tail_(size_t len); + // Keep the whole chunk in tail_ and continue from sent; false when the tail cannot be allocated. + bool stash_chunk_(const char *prefix, size_t prefix_len, const char *message, size_t message_len, size_t total, + size_t sent); + // Send a state event; JSON too large for the stack buffer is serialized into tail_ instead + bool send_json_(json::JsonBuilder &builder); + // Warn once, and close the session once the stall timeout passes with no memory for the tail + void tail_alloc_failed_(size_t cap); void request_close_(); + + // A log line emitted inside a send re-enters try_send_nodefer on this session; refuse it + struct SendGuard { + AsyncEventSourceResponse &owner; + explicit SendGuard(AsyncEventSourceResponse &owner) : owner(owner) { owner.sending_ = true; } + ~SendGuard() { this->owner.sending_ = false; } + }; void process_close_(); static void close_session_work(void *arg); @@ -317,15 +342,33 @@ class AsyncEventSourceResponse { std::vector deferred_queue_; esphome::web_server::WebServer *web_server_; esphome::web_server::ListEntitiesIterator entities_iterator_; - std::string event_buffer_; - size_t event_bytes_sent_; + // One chunk the socket did not take whole, allocated on the first stall; the only heap use + // on the send path + RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; - // Main-loop only; the HTTPD task never reads or writes this flag. - bool close_requested_{false}; - bool close_retry_warning_logged_{false}; + uint16_t tail_cap_{0}; + uint16_t tail_len_{0}; // Zero means nothing pending + uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + // Main-loop only; the HTTPD task never reads or writes these flags. + bool close_requested_{false}; + bool close_retry_warning_logged_{false}; + bool sending_{false}; + // The longest multi line log message in the tree (a climate dump_config) has 22 lines; a + // longer one goes through the tail + static constexpr size_t MAX_SEND_LINES = 22; + static constexpr size_t MAX_SEND_IOV = 1 + 2 * MAX_SEND_LINES; + // Chunk header, retry/id/event lines and the first "data: " + static constexpr size_t PREFIX_BUF_SIZE = 128; + // Stack buffer for a state event's JSON; a larger document is serialized into the tail + static constexpr size_t JSON_BUF_SIZE = 1024; + // Same ceiling JsonBuilder::serialize() applies (max_heap_size in json_util.cpp); a larger + // document is dropped before anything is on the wire + static constexpr size_t JSON_MAX_SIZE = 5120; + // Most RAM a stalled session keeps: the largest state document plus any accepted framing + static constexpr size_t TAIL_MAX_SIZE = JSON_MAX_SIZE + PREFIX_BUF_SIZE + SSE_SUFFIX_LEN; static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; diff --git a/tests/components/web_server_idf/test_sse_chunk.cpp b/tests/components/web_server_idf/test_sse_chunk.cpp new file mode 100644 index 00000000000..4e34f90d774 --- /dev/null +++ b/tests/components/web_server_idf/test_sse_chunk.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/web_server_idf/sse_chunk.h" + +namespace esphome::web_server_idf::testing { + +// The chunk as try_send_nodefer lays it out: header, fields, first "data: ", then the pieces +static std::string build_chunk(const char *message, size_t message_len, const char *event, uint32_t id, + uint32_t reconnect) { + char prefix[128]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), event, id, reconnect, message != nullptr); + if (message == nullptr && prefix_len == CHUNK_HDR_LEN) { + return ""; + } + std::string out(prefix, prefix_len); + for_each_chunk_piece( + message, message_len, + [](void *ctx, const char *piece, size_t len) { static_cast(ctx)->append(piece, len); }, &out); + write_chunk_header(prefix, out.size() - CHUNK_HDR_LEN - CHUNK_END_LEN); + out.replace(0, CHUNK_HDR_LEN, prefix, CHUNK_HDR_LEN); + return out; +} + +// The std::string builder this framing replaced, kept as the reference for the wire format +static std::string reference_chunk(const char *message, size_t message_len, const char *event, uint32_t id, + uint32_t reconnect) { + std::string buf = " \r\n"; + char num[32]; + if (reconnect) + buf.append(num, snprintf(num, sizeof(num), "retry: %u\r\n", reconnect)); + if (id) + buf.append(num, snprintf(num, sizeof(num), "id: %u\r\n", id)); + if (event && *event) + buf.append("event: ").append(event).append("\r\n"); + if (message) { + const char *line_start = message; + const char *msg_end = message + message_len; + const char *next_n = static_cast(memchr(message, '\n', message_len)); + const char *next_r = static_cast(memchr(message, '\r', message_len)); + if (next_n == nullptr && next_r == nullptr) { + buf.append("data: ").append(message, message_len).append("\r\n\r\n"); + } else { + while (line_start <= msg_end) { + const char *line_end; + const char *next_line; + if (next_n == nullptr && next_r == nullptr) { + buf.append("data: ").append(line_start, msg_end - line_start).append("\r\n"); + break; + } + if (next_n != nullptr && next_r != nullptr) { + if (next_r + 1 == next_n) { + line_end = next_r; + next_line = next_n + 1; + } else { + line_end = (next_r < next_n) ? next_r : next_n; + next_line = line_end + 1; + } + } else if (next_n != nullptr) { + line_end = next_n; + next_line = next_n + 1; + } else { + line_end = next_r; + next_line = next_r + 1; + } + buf.append("data: ").append(line_start, line_end - line_start).append("\r\n"); + line_start = next_line; + if (line_start >= msg_end) + break; + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); + } + buf.append("\r\n"); + } + } + if (buf.size() == 10) + return ""; + buf.append("\r\n"); + char len[9]; + snprintf(len, sizeof(len), "%08x", static_cast(buf.size() - 2 - 10)); + buf.replace(0, 8, len, 8); + return buf; +} + +static void expect_same(const std::string &message, const char *event, uint32_t id, uint32_t reconnect) { + const std::string got = build_chunk(message.data(), message.size(), event, id, reconnect); + const std::string want = reference_chunk(message.data(), message.size(), event, id, reconnect); + EXPECT_EQ(got, want) << "message=[" << message << "] event=" << (event ? event : "null") << " id=" << id + << " retry=" << reconnect; +} + +TEST(SseChunk, NullMessageHasNoDataLineAndNoBlankLine) { + EXPECT_EQ(build_chunk(nullptr, 0, "ping", 7, 30000), reference_chunk(nullptr, 0, "ping", 7, 30000)); + EXPECT_EQ(build_chunk(nullptr, 0, "ping", 7, 30000), "00000022\r\nretry: 30000\r\nid: 7\r\nevent: ping\r\n\r\n"); + EXPECT_EQ(build_chunk(nullptr, 0, nullptr, 0, 0), ""); +} + +TEST(SseChunk, SingleLine) { + EXPECT_EQ(build_chunk("{}", 2, "state", 0, 0), "0000001a\r\nevent: state\r\ndata: {}\r\n\r\n\r\n"); + expect_same("", "state", 0, 0); + expect_same(R"({"id":"light-x"})", "state_detail_all", 0, 0); +} + +TEST(SseChunk, LineBreaks) { + for (const char *m : {"a\n", "a\r", "a\r\n", "\n", "\r\n", "\r", "a\n\nb", "a\r\rb", "a\n\rb", "a\r\n\r\nb", "\n\n", + "x\r\n", "\r\nx", "one\ntwo\nthree", "tail\r\n\r\n"}) { + expect_same(m, "log", 1234, 0); + } + EXPECT_EQ(build_chunk("a\r\nb", 4, nullptr, 0, 0), "00000014\r\ndata: a\r\ndata: b\r\n\r\n\r\n"); + EXPECT_EQ(build_chunk("a\n\rb", 4, nullptr, 0, 0), "0000001c\r\ndata: a\r\ndata: \r\ndata: b\r\n\r\n\r\n"); +} + +TEST(SseChunk, MatchesReferenceOnRandomMessages) { + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + const char *events[] = {nullptr, "", "ping", "state", "log", "state_detail_all", "sorting_group"}; + const char alphabet[] = "ab{}\":,\n\r "; + for (int i = 0; i < 20000; i++) { + std::string m; + const size_t len = rng() % 120; + for (size_t k = 0; k < len; k++) + m += alphabet[rng() % (sizeof(alphabet) - 1)]; + expect_same(m, events[rng() % 7], (rng() % 3 == 0) ? 0 : rng(), (rng() % 4 == 0) ? 30000 : 0); + } +} + +} // namespace esphome::web_server_idf::testing From 25ddc94d5d0cec4ff74f632fac214e7358874aab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 00:10:40 +0200 Subject: [PATCH 728/759] [web_server_idf] Build state documents in a stack arena so a state event does no heap allocation (#19383) --- esphome/components/json/__init__.py | 6 + esphome/components/json/json_util.cpp | 19 +- esphome/components/json/json_util.h | 84 ++++++- esphome/components/web_server/__init__.py | 4 + .../web_server_idf/web_server_idf.cpp | 27 +- .../web_server_idf/web_server_idf.h | 3 +- esphome/core/defines.h | 1 + tests/components/json/__init__.py | 12 + tests/components/json/test_json_arena.cpp | 238 ++++++++++++++++++ 9 files changed, 375 insertions(+), 19 deletions(-) create mode 100644 tests/components/json/test_json_arena.cpp diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index af7eb7e733d..618c56faa14 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -21,3 +21,9 @@ async def to_code(config: ConfigType) -> None: cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) + + +def enable_arena() -> None: + """Compile JsonArena and the allocator constructor of JsonBuilder; only the consumers that build + documents in a stack arena pay for them.""" + cg.add_define("USE_JSON_ARENA") diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 193390e4782..3ecfce8b699 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -46,11 +46,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object } -#ifdef USE_PSRAM - JsonDocument json_document(&global_json_allocator); -#else - JsonDocument json_document; -#endif + JsonDocument json_document(heap_json_allocator()); if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return JsonObject(); // return unbound object @@ -68,7 +64,18 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } -JsonBuilder::JsonBuilder() = default; +JsonBuilder::JsonBuilder() : doc_(heap_json_allocator()) {} +#ifdef USE_JSON_ARENA +JsonBuilder::JsonBuilder(ArduinoJson::Allocator *allocator) : doc_(allocator) {} +#endif + +ArduinoJson::Allocator *heap_json_allocator() { +#ifdef USE_PSRAM + return &global_json_allocator; +#else + return ArduinoJson::detail::DefaultAllocator::instance(); +#endif +} size_t JsonBuilder::serialize_to(char *buf, size_t cap) { if (doc_.overflowed()) { diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 5823e993085..be12d0aa38a 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -165,11 +167,88 @@ inline JsonDocument parse_json(const std::string &data) { return parse_json(reinterpret_cast(data.c_str()), data.size()); } +/// The allocator a JsonBuilder uses by default (PSRAM first when available) +ArduinoJson::Allocator *heap_json_allocator(); + +#ifdef USE_JSON_ARENA +/// Size of one ArduinoJson slot pool, the first allocation every document makes (1 KB on 32 bit targets) +constexpr size_t JSON_POOL_BYTES = ARDUINOJSON_POOL_CAPACITY * sizeof(ArduinoJson::detail::VariantData); +/// One pool plus room for copied string nodes; a 40 option select with linked options fits +constexpr size_t JSON_ARENA_SIZE = JSON_POOL_BYTES + 1152; +static_assert(sizeof(void *) != 4 || JSON_ARENA_SIZE == 2176, "the arena was sized for a 1 KB pool"); + +/// Bump allocator over a fixed buffer for a document built and serialized in one scope. What the +/// buffer cannot hold goes to the heap allocator; nothing is freed until the arena goes away. +template class JsonArena final : public ArduinoJson::Allocator { + public: + // Takes what the buffer cannot hold + explicit JsonArena(ArduinoJson::Allocator *fallback = heap_json_allocator()) : fallback_(fallback) {} + // The document points into buf_ + JsonArena(const JsonArena &) = delete; + JsonArena &operator=(const JsonArena &) = delete; + + void *allocate(size_t size) override { + if (size > N) { + return this->fallback_->allocate(size); // also keeps the rounding below from wrapping + } + size = (size + ALIGN - 1) & ~(ALIGN - 1); + if (size > N - this->used_) { + return this->fallback_->allocate(size); + } + this->last_ = this->used_; + this->used_ += size; + return this->buf_ + this->last_; + } + void deallocate(void *ptr) override { + if (!this->owns_(ptr)) { + this->fallback_->deallocate(ptr); + } + } + void *reallocate(void *ptr, size_t new_size) override { + if (!this->owns_(ptr)) { + return this->fallback_->reallocate(ptr, new_size); + } + const size_t off = static_cast(ptr) - this->buf_; + const size_t size = new_size > N ? N + ALIGN : (new_size + ALIGN - 1) & ~(ALIGN - 1); + const bool newest = off == this->last_; + if (newest && size <= N - off) { + this->used_ = off + size; // the newest block grows or shrinks in place + return ptr; + } + // An older block's size is unknown; copying to the end of the buffer stays in bounds + const size_t old_size = newest ? this->used_ - off : N - off; + void *moved = this->fallback_->allocate(new_size); + if (moved == nullptr) { + return nullptr; // the caller keeps ptr, so its arena space stays reserved + } + std::memcpy(moved, ptr, std::min(new_size, old_size)); + if (newest) { + this->used_ = off; // it moved to the heap, so its arena space is free again + } + return moved; + } + /// Bytes of the buffer handed out so far + size_t used() const { return this->used_; } + + private: + static constexpr size_t ALIGN = alignof(std::max_align_t); + bool owns_(const void *ptr) const { return ptr >= this->buf_ && ptr < this->buf_ + N; } + ArduinoJson::Allocator *fallback_; + alignas(ALIGN) uint8_t buf_[N]; + size_t used_{0}; + size_t last_{0}; +}; +#endif // USE_JSON_ARENA + /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: // Out of line: inlining the JsonDocument constructor duplicates it at every call site JsonBuilder(); +#ifdef USE_JSON_ARENA + // The builder must not outlive the allocator + explicit JsonBuilder(ArduinoJson::Allocator *allocator); +#endif JsonObject root() { if (!root_created_) { @@ -188,12 +267,7 @@ class JsonBuilder { SerializationBuffer<> serialize(); private: -#ifdef USE_PSRAM - SpiRamAllocator allocator_; - JsonDocument doc_{&allocator_}; -#else JsonDocument doc_; -#endif JsonObject root_; bool root_created_{false}; }; diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 3ec365cf381..2b00a6f8de1 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -8,6 +8,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import web_server_base +from esphome.components.json import enable_arena from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv @@ -387,6 +388,9 @@ async def to_code(config: ConfigType) -> None: cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") cg.add_define("USE_WEBSERVER_PORT", port) + if CORE.is_esp32: + # The ESP-IDF event source builds state documents in a stack arena + enable_arena() cg.add_define("USE_WEBSERVER_VERSION", version) if version >= 2: # Don't compress the index HTML as the data sizes are almost the same. diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 837a705cd36..510b4e50f1a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -57,6 +57,12 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; +// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one +// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it. +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic error "-Wstack-usage=2048" +#endif + // Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. // Buffers of this size must live on the heap - the httpd task stack is too small. static constexpr size_t RECV_CHUNK_SIZE = 1460; @@ -893,9 +899,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - json::JsonBuilder builder; - de.message_generator_(web_server_, de.source_, builder); - if (this->send_json_(builder)) { + if (this->send_json_(de.source_, de.message_generator_)) { if (this->close_requested_ || deferred_queue_.empty()) { return; } @@ -1086,7 +1090,15 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.try_advance(1); } -bool AsyncEventSourceResponse::send_json_(json::JsonBuilder &builder) { +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer +#endif +bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) { + // The arena lives only in this frame, so no call chain ever holds two of them + json::JsonArena arena; + json::JsonBuilder builder(&arena); + generator(this->web_server_, source, builder); char buf[JSON_BUF_SIZE]; const size_t len = builder.serialize_to(buf, sizeof(buf)); if (len < sizeof(buf)) { @@ -1138,6 +1150,9 @@ bool AsyncEventSourceResponse::send_json_(json::JsonBuilder &builder) { drain_tail_(); return true; } +#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) +#pragma GCC diagnostic pop +#endif void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) { // Same stall clock as a socket that stops draining, so a session cannot retry forever @@ -1250,10 +1265,8 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e // trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - json::JsonBuilder builder; - message_generator(web_server_, source, builder); // A send error closes the session and clears the queue; nothing is queued after that - if (!this->send_json_(builder) && !this->close_requested_) { + if (!this->send_json_(source, message_generator) && !this->close_requested_) { deq_push_back_with_dedup_(source, message_generator); } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 0f0977229c6..2b62d68790e 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -316,7 +316,7 @@ class AsyncEventSourceResponse { bool stash_chunk_(const char *prefix, size_t prefix_len, const char *message, size_t message_len, size_t total, size_t sent); // Send a state event; JSON too large for the stack buffer is serialized into tail_ instead - bool send_json_(json::JsonBuilder &builder); + bool send_json_(void *source, message_generator_t *generator); // Warn once, and close the session once the stall timeout passes with no memory for the tail void tail_alloc_failed_(size_t cap); void request_close_(); @@ -362,6 +362,7 @@ class AsyncEventSourceResponse { static constexpr size_t MAX_SEND_IOV = 1 + 2 * MAX_SEND_LINES; // Chunk header, retry/id/event lines and the first "data: " static constexpr size_t PREFIX_BUF_SIZE = 128; + // Stack buffer for a state event's JSON; a larger document is serialized into the tail static constexpr size_t JSON_BUF_SIZE = 1024; // Same ceiling JsonBuilder::serialize() applies (max_heap_size in json_util.cpp); a larger diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3397214c9b6..6ee6f97947d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -87,6 +87,7 @@ #define USE_INFRARED #define USE_IR_RF #define USE_JSON +#define USE_JSON_ARENA #define USE_RADIO_FREQUENCY #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH diff --git a/tests/components/json/__init__.py b/tests/components/json/__init__.py index 40ec1f996ea..37e4dba021b 100644 --- a/tests/components/json/__init__.py +++ b/tests/components/json/__init__.py @@ -1,3 +1,6 @@ +import functools + +from esphome.components.json import enable_arena from tests.testing_helpers import ComponentManifestOverride @@ -7,3 +10,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # library registration to happen, otherwise json_util.cpp fails to find # ArduinoJson.h. manifest.enable_codegen() + # The JsonArena host test needs the arena compiled in, as a consumer would request it + real_to_code = manifest.to_code + + @functools.wraps(real_to_code) + async def to_code_with_arena(config): + await real_to_code(config) + enable_arena() + + manifest.to_code = to_code_with_arena diff --git a/tests/components/json/test_json_arena.cpp b/tests/components/json/test_json_arena.cpp new file mode 100644 index 00000000000..8d1d44d1bb0 --- /dev/null +++ b/tests/components/json/test_json_arena.cpp @@ -0,0 +1,238 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/json/json_util.h" + +using esphome::json::JsonArena; +using esphome::json::JsonBuilder; + +namespace { + +constexpr size_t ALIGN = alignof(std::max_align_t); +constexpr size_t round_up(size_t n) { return (n + ALIGN - 1) & ~(ALIGN - 1); } + +// Counts what the arena could not hold +struct Counting final : ArduinoJson::Allocator { + int allocs{0}; + void *allocate(size_t n) override { + this->allocs++; + return malloc(n); // NOLINT + } + void deallocate(void *p) override { free(p); } // NOLINT + void *reallocate(void *p, size_t n) override { + this->allocs++; + return realloc(p, n); // NOLINT + } +}; + +// Refuses everything, so a spill or a move sees the heap as exhausted +struct NoMemory final : ArduinoJson::Allocator { + void *allocate(size_t) override { return nullptr; } + void deallocate(void *) override {} + void *reallocate(void *, size_t) override { return nullptr; } +}; + +template bool inside(const JsonArena &arena, const void *p) { + auto base = reinterpret_cast(&arena); + auto addr = reinterpret_cast(p); + return addr >= base && addr < base + sizeof(arena); +} + +} // namespace + +TEST(JsonArena, BumpsAlignedInsideTheBuffer) { + JsonArena<256> arena; + auto *a = static_cast(arena.allocate(10)); + auto *b = static_cast(arena.allocate(10)); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + EXPECT_TRUE(inside(arena, a)); + EXPECT_TRUE(inside(arena, b)); + EXPECT_EQ(reinterpret_cast(a) % ALIGN, 0u); + EXPECT_EQ(static_cast(b - a), round_up(10)); + arena.deallocate(a); + arena.deallocate(b); +} + +TEST(JsonArena, SpillsToTheHeapWhenFull) { + JsonArena<64> arena; + void *a = arena.allocate(48); + void *b = arena.allocate(48); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + EXPECT_TRUE(inside(arena, a)); + EXPECT_FALSE(inside(arena, b)); + std::memset(b, 'b', 48); + arena.deallocate(b); // routed to the heap; a mismatch would trip the sanitizer + arena.deallocate(a); +} + +TEST(JsonArena, NewestBlockGrowsAndShrinksInPlace) { + JsonArena<256> arena; + void *a = arena.allocate(16); + std::memset(a, 'x', 16); + EXPECT_EQ(arena.reallocate(a, 96), a); + EXPECT_EQ(std::memcmp(a, "xxxxxxxxxxxxxxxx", 16), 0); + EXPECT_EQ(arena.reallocate(a, 8), a); + auto *next = static_cast(arena.allocate(8)); + EXPECT_EQ(static_cast(next - static_cast(a)), round_up(8)); +} + +TEST(JsonArena, NewestBlockMovesToTheHeapAndFreesItsSpace) { + JsonArena<64> arena; + void *a = arena.allocate(32); + std::memset(a, 'q', 32); + void *moved = arena.reallocate(a, 200); + ASSERT_NE(moved, nullptr); + EXPECT_FALSE(inside(arena, moved)); + EXPECT_EQ(std::memcmp(moved, "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", 32), 0); + EXPECT_EQ(arena.allocate(16), a); // the space it left is handed out again + arena.deallocate(moved); +} + +TEST(JsonArena, OlderBlockMovesToTheHeapKeepingItsBytes) { + JsonArena<256> arena; + void *a = arena.allocate(16); + std::memset(a, 'a', 16); + auto *b = static_cast(arena.allocate(16)); + void *moved = arena.reallocate(a, 64); + ASSERT_NE(moved, nullptr); + EXPECT_FALSE(inside(arena, moved)); + EXPECT_EQ(std::memcmp(moved, "aaaaaaaaaaaaaaaa", 16), 0); + auto *next = static_cast(arena.allocate(8)); + EXPECT_EQ(static_cast(next - b), round_up(16)); // b's space is untouched + arena.deallocate(moved); +} + +TEST(JsonArena, HeapBlocksReallocateOnTheHeap) { + JsonArena<32> arena; + void *a = arena.allocate(64); // never fit + EXPECT_FALSE(inside(arena, a)); + std::memset(a, 'h', 64); + void *grown = arena.reallocate(a, 128); + ASSERT_NE(grown, nullptr); + EXPECT_EQ(std::memcmp(grown, "hhhhhhhhhhhhhhhh", 16), 0); + arena.deallocate(grown); +} + +TEST(JsonArena, FailedMoveKeepsTheBlockReserved) { + NoMemory no_memory; + JsonArena<64> arena(&no_memory); + void *a = arena.allocate(32); + std::memset(a, 'k', 32); + EXPECT_EQ(arena.reallocate(a, 200), nullptr); + EXPECT_EQ(std::memcmp(a, "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk", 32), 0); + auto *b = static_cast(arena.allocate(16)); // must not hand out a's bytes again + ASSERT_NE(b, nullptr); + EXPECT_EQ(static_cast(b - static_cast(a)), round_up(32)); + EXPECT_EQ(arena.allocate(64), nullptr); // nothing left and the fallback refuses +} + +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +constexpr size_t ARENA = esphome::json::JSON_ARENA_SIZE; + +// The documents the event stream sends must fit without touching the fallback, and their copied +// strings must land in the headroom above the pool +TEST(JsonArena, StateDocumentsFitWithoutTouchingTheFallback) { + Counting counting; + JsonArena arena(&counting); + { + // A switch state event: copied id, domain and name, bool value + JsonBuilder builder(&arena); + JsonObject root = builder.root(); + char id_buf[] = "switch/SSE Toggle"; + char domain_buf[] = "switch"; + char name_buf[] = "SSE Toggle"; + root["id"] = static_cast(id_buf); + root["domain"] = static_cast(domain_buf); + root["name"] = static_cast(name_buf); + root["icon"] = ""; + root["entity_category"] = 0; + root["value"] = true; + root["state"] = "ON"; + root["assumed_state"] = false; + char out[256]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_EQ(counting.allocs, 0); + EXPECT_GT(arena.used(), esphome::json::JSON_POOL_BYTES); + + Counting counting_select; + JsonArena select_arena(&counting_select); + { + // A 40 option select detail document: copied id, domain, name and value, linked options + JsonBuilder builder(&select_arena); + JsonObject root = builder.root(); + char id_buf[] = "select/SSE Big Select"; + char domain_buf[] = "select"; + char name_buf[] = "SSE Big Select"; + char value_buf[] = "option number 17 padded to twenty"; + root["id"] = static_cast(id_buf); + root["domain"] = static_cast(domain_buf); + root["name"] = static_cast(name_buf); + root["icon"] = ""; + root["entity_category"] = 0; + root["value"] = static_cast(value_buf); + root["state"] = static_cast(value_buf); + JsonArray options = root["option"].to(); + char option_bufs[40][34]; + for (int i = 0; i < 40; i++) { + snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); + options.add(JsonString(option_bufs[i], true)); + } + char out[2048]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_EQ(counting_select.allocs, 0); + EXPECT_GT(select_arena.used(), esphome::json::JSON_POOL_BYTES); + + // The same select with its 40 options copied, as the generator does before the strings are + // linked, does not fit: the headroom is sized for linked options and the rest spills + Counting counting_copied; + JsonArena copied_arena(&counting_copied); + { + JsonBuilder builder(&copied_arena); + JsonArray options = builder.root()["option"].to(); + char option_bufs[40][34]; + for (int i = 0; i < 40; i++) { + snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); + options.add(static_cast(option_bufs[i])); + } + char out[2048]; + EXPECT_LT(builder.serialize_to(out, sizeof(out)), sizeof(out)); + } + EXPECT_GT(counting_copied.allocs, 0); + EXPECT_GT(copied_arena.used(), ARENA - 64); // the arena filled up before the spill began +} + +TEST(JsonArena, DocumentMatchesTheHeapAllocator) { + // 700 integers need six pools, which also grows ArduinoJson's pool list past its preallocated four + auto build = [](JsonBuilder &builder) { + JsonArray arr = builder.root()["a"].to(); + for (int i = 0; i < 700; i++) { + arr.add(i); + } + JsonArray strings = builder.root()["s"].to(); + char buf[32]; + for (int i = 0; i < 60; i++) { + snprintf(buf, sizeof(buf), "string number %04d padded", i); + strings.add(buf); + } + }; + JsonArena arena; + JsonBuilder with_arena(&arena); + build(with_arena); + JsonBuilder with_heap; + build(with_heap); + std::string a = with_arena.serialize(); + std::string b = with_heap.serialize(); + EXPECT_GT(a.size(), 4000u); + EXPECT_EQ(a, b); +} +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) From 7187acec04495a63f960e249c2eecc0d09497d6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 00:15:14 +0200 Subject: [PATCH 729/759] [core] Add a line based yaml editor and use it for rename (#19410) --- esphome/__main__.py | 154 +---- esphome/cli/__init__.py | 2 + esphome/cli/rename.py | 164 ++++++ esphome/util.py | 4 + esphome/yaml_edit.py | 114 ++++ tests/unit_tests/cli/__init__.py | 0 tests/unit_tests/cli/test_rename.py | 839 ++++++++++++++++++++++++++++ tests/unit_tests/test_main.py | 631 +-------------------- tests/unit_tests/test_yaml_edit.py | 182 ++++++ 9 files changed, 1316 insertions(+), 774 deletions(-) create mode 100644 esphome/cli/__init__.py create mode 100644 esphome/cli/rename.py create mode 100644 esphome/yaml_edit.py create mode 100644 tests/unit_tests/cli/__init__.py create mode 100644 tests/unit_tests/cli/test_rename.py create mode 100644 tests/unit_tests/test_yaml_edit.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 01ca8ca5d96..49fc7a3020f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Protocol from esphome import const, platform_hooks from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( - ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, @@ -36,7 +35,6 @@ from esphome.const import ( CONF_LOGGER, CONF_MDNS, CONF_MQTT, - CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, @@ -62,6 +60,7 @@ from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType from esphome.upload_targets import PortType, get_port_type from esphome.util import ( + ESPHOME_COMMAND, PICOTOOL_PACKAGE, FlashImage, detect_rp2040_bootsel, @@ -85,7 +84,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 @@ -2104,155 +2102,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome import yaml_util + from esphome.cli.rename import command_rename as run - new_name = args.name - for c in new_name: - if c not in ALLOWED_NAME_CHARS: - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{c}' is an invalid character for names. Valid characters are: " - f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)", - ) - ) - return 1 - # Load existing yaml file - raw_contents = CORE.config_path.read_text(encoding="utf-8") - - yaml = yaml_util.load_yaml(CORE.config_path) - if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - safe_print( - color( - AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." - ) - ) - return 1 - old_name = yaml[CONF_ESPHOME][CONF_NAME] - match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name) - if match is None: - # Only swap the ``name:`` line that sits directly under the - # top-level ``esphome:`` block. A naked ``re.sub`` would - # also clobber any other ``name:`` line whose value happens - # to match (e.g. a sensor / output / wifi entry sharing the - # device's hostname), silently rewriting unrelated user - # configuration. The pattern anchors: - # - at the start of the line so ``friendly_name:``, - # ``device_name:`` etc. don't match the trailing ``name:`` - # substring; and - # - at the end of the value (lookahead for whitespace + - # comment + EOL) so ``old_name`` doesn't match as a - # prefix of a longer value (``kitchen`` vs ``kitchen2``). - name_pattern = re.compile( - rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))" - ) - out_lines: list[str] = [] - in_esphome_block = False - for line in raw_contents.splitlines(keepends=True): - if line and not line[0].isspace() and line.strip(): - in_esphome_block = line.lstrip().startswith("esphome:") - out_lines.append(line) - continue - if in_esphome_block: - line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1) - out_lines.append(line) - new_raw = "".join(out_lines) - else: - old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)] - if ( - len( - re.findall( - rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?", - raw_contents, - flags=re.MULTILINE, - ) - ) - > 1 - ): - safe_print( - color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") - ) - return 1 - - new_raw = re.sub( - rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?", - f'\\1: "{new_name}"', - raw_contents, - flags=re.MULTILINE, - ) - - # ``new_name == old_name`` (after substitution resolution) is - # a no-op rewrite that would still queue a pointless re-flash. - # Catch it before the path-equality check below — covers the - # case where the config filename doesn't match the device name - # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is - # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` - # would otherwise just re-flash the same hostname). - if new_name == old_name: - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{new_name}' is already the device's name.", - ) - ) - return 1 - - new_path: Path = CORE.config_dir / (new_name + ".yaml") - if new_path.resolve() == CORE.config_path.resolve(): - safe_print( - color( - AnsiFore.BOLD_RED, - f"'{new_name}' is already the device's name.", - ) - ) - return 1 - if new_path.exists(): - safe_print( - color( - AnsiFore.BOLD_RED, - f"Cannot rename: {new_path} already exists. " - "Refusing to overwrite an existing configuration.", - ) - ) - return 1 - safe_print( - f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" - ) - print() - - new_path.write_text(new_raw, encoding="utf-8") - - rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) - if rc != 0: - safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) - new_path.unlink() - return 1 - - cli_args = [ - "run", - str(new_path), - "--no-logs", - "--device", - CORE.address, - ] - - if args.dashboard: - cli_args.insert(0, "--dashboard") - - try: - rc = run_external_process(*ESPHOME_COMMAND, *cli_args) - except KeyboardInterrupt: - rc = 1 - if rc != 0: - new_path.unlink() - return 1 - - if CORE.config_path != new_path: - CORE.config_path.unlink() - - safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) - print() - return 0 + return run(args, config) PRE_CONFIG_ACTIONS = { diff --git a/esphome/cli/__init__.py b/esphome/cli/__init__.py new file mode 100644 index 00000000000..fa6e1fa266c --- /dev/null +++ b/esphome/cli/__init__.py @@ -0,0 +1,2 @@ +"""Commands of the esphome command line, one module each, imported by +__main__ only when they run so that startup stays light.""" diff --git a/esphome/cli/rename.py b/esphome/cli/rename.py new file mode 100644 index 00000000000..cbef68fb70f --- /dev/null +++ b/esphome/cli/rename.py @@ -0,0 +1,164 @@ +"""``esphome rename``.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re + +from esphome import yaml_edit, yaml_util +from esphome.const import ( + ALLOWED_NAME_CHARS, + CONF_ESPHOME, + CONF_NAME, + CONF_SUBSTITUTIONS, +) +from esphome.core import CORE, EsphomeError +from esphome.log import AnsiFore, color +from esphome.types import ConfigType +from esphome.util import ESPHOME_COMMAND, run_external_process, safe_print + + +def _revert(new_path: Path, why: str) -> int: + """Say why the rename stopped and take the new file back; an orphan the + next attempt would trip over is reported.""" + safe_print(color(AnsiFore.BOLD_RED, f"Rename failed: {why}")) + try: + new_path.unlink(missing_ok=True) + except OSError as err: + safe_print(color(AnsiFore.BOLD_RED, f"Could not remove {new_path}: {err}")) + return 1 + + +def command_rename(args: argparse.Namespace, config: ConfigType) -> int | None: + """Rename the device: a new file with the name line rewritten, validated + and installed, then the old file removed.""" + new_name = args.name + for c in new_name: + if c not in ALLOWED_NAME_CHARS: + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{c}' is an invalid character for names. Valid characters are: " + f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)", + ) + ) + return 1 + + yaml = yaml_util.load_yaml(CORE.config_path) + + def name_edit() -> tuple[str, yaml_edit.LineEdit]: + """The name and the line to rewrite: the name's own line, or the + substitution's line it comes from, as a plain value in this file.""" + esphome_conf = yaml.get(CONF_ESPHOME) + if not isinstance(esphome_conf, dict) or CONF_NAME not in esphome_conf: + raise EsphomeError(f"no '{CONF_ESPHOME}: {CONF_NAME}:' in the file") + old_name = str(esphome_conf[CONF_NAME]) + mapping, field = esphome_conf, CONF_NAME + if match := re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name): + mapping, field = yaml.get(CONF_SUBSTITUTIONS), match.group(1) + if not isinstance(mapping, dict) or field not in mapping: + raise EsphomeError(f"the substitution '{field}' is not in the file") + old_name = str(mapping[field]) + # Only read here; the rewritten text goes to a new file, so the + # source may live anywhere the config path points to + source = yaml_edit.source_of(mapping, field) + if source is None or source[0].resolve() != CORE.config_path.resolve(): + raise EsphomeError(f"'{field}' was not read from {CORE.config_path}") + doc, line_no = source + text = yaml_edit.line_at(doc, line_no) + if (line_match := yaml_edit.field_line_re(field, old_name).match(text)) is None: + raise EsphomeError(f"'{field}' is not a plain value on {doc}:{line_no + 1}") + # The new value is always quoted, whatever the old line had + return old_name, yaml_edit.LineEdit( + doc, line_no, text, yaml_edit.rewrite(line_match, new_name, '"') + ) + + try: + old_name, edit = name_edit() + except EsphomeError as err: + safe_print( + color( + AnsiFore.BOLD_RED, + f"Complex YAML files cannot be automatically renamed: {err}", + ) + ) + return 1 + + # ``new_name == old_name`` (after substitution resolution) is + # a no-op rewrite that would still queue a pointless re-flash. + # Catch it before the path-equality check below — covers the + # case where the config filename doesn't match the device name + # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is + # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` + # would otherwise just re-flash the same hostname). + if new_name == old_name: + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + + new_path: Path = CORE.config_dir / (new_name + ".yaml") + if new_path.resolve() == CORE.config_path.resolve(): + safe_print( + color( + AnsiFore.BOLD_RED, + f"'{new_name}' is already the device's name.", + ) + ) + return 1 + if new_path.exists(): + safe_print( + color( + AnsiFore.BOLD_RED, + f"Cannot rename: {new_path} already exists. " + "Refusing to overwrite an existing configuration.", + ) + ) + return 1 + safe_print( + f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" + ) + print() + + try: + yaml_edit.write_keeping_mode( + new_path, + yaml_edit.rewritten_text(yaml_edit.read_text(CORE.config_path), [edit]), + like=CORE.config_path, + ) + except EsphomeError as err: + return _revert(new_path, str(err)) + + if run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) != 0: + return _revert(new_path, "the new configuration does not validate") + + cli_args = [ + "run", + str(new_path), + "--no-logs", + "--device", + CORE.address, + ] + + if args.dashboard: + cli_args.insert(0, "--dashboard") + + try: + rc = run_external_process(*ESPHOME_COMMAND, *cli_args) + except KeyboardInterrupt: + rc = 1 + if rc != 0: + return _revert( + new_path, + "the install did not finish; the device may already run the new name", + ) + + CORE.config_path.unlink() + + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + print() + return 0 diff --git a/esphome/util.py b/esphome/util.py index 8aa321d908c..dd1998a7c29 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -352,6 +352,10 @@ def run_external_command( return retval +# How a command starts another esphome, as a child of this one +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] + + def run_external_process(*cmd: str, **kwargs: Any) -> int | str: # Deferred: an OTA upload/logs run never spawns an external process. import subprocess diff --git a/esphome/yaml_edit.py b/esphome/yaml_edit.py new file mode 100644 index 00000000000..da66fa088fc --- /dev/null +++ b/esphome/yaml_edit.py @@ -0,0 +1,114 @@ +"""Rewrite single lines of a yaml file in place, located by the source +ranges the loader keeps, so quotes, comments, indentation and line endings +around them survive and nothing else in the file is touched.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +import stat + +from esphome.core import EsphomeError +from esphome.helpers import write_file +from esphome.types import ConfigType + +# A plain scalar with no yaml indicator, so an empty value, a block scalar +# (`>-`, `|`) or a flow collection never counts; then optional matching +# quotes and the trailer, where a comment needs whitespace before its `#` +PLAIN_SCALAR = r"[^\s#\"'>|&*!%@`\[\]{},]+" +TRAILER = r"(?P[\"']?){value}(?P=quote)(?P(?:\s+#.*)?\s*)$" + + +def read_text(path: Path) -> str: + """The file as written, line endings included; read_file would fold them.""" + try: + return path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError) as err: + raise EsphomeError(f"Error reading file {path}: {err}") from err + + +@dataclass +class LineEdit: + """One line to rewrite; ``old_line`` is what it held when located.""" + + path: Path + line: int + old_line: str + new_line: str + + +def field_line_re( + name: str, value: str | None = None, indent: str = r"\s*" +) -> re.Pattern[str]: + """Match ``name: value``, or ``name:`` with any plain scalar, at + ``indent``; the name may be quoted. Keeps the prefix, the quotes and the + trailer for rewrite.""" + scalar = PLAIN_SCALAR if value is None else re.escape(value) + prefix = rf"{indent}[\"']?{re.escape(name)}[\"']?\s*:\s*" + return re.compile(rf"^(?P{prefix}){TRAILER.format(value=scalar)}") + + +def rewrite(match: re.Match[str], value: str, quote: str | None = None) -> str: + """The matched line with ``value`` in place of the scalar; the source + quotes stay unless ``quote`` is given.""" + quote = match["quote"] if quote is None else quote + return f"{match['prefix']}{quote}{value}{quote}{match['trail']}" + + +def line_at(doc: Path, line_no: int) -> str: + lines = read_text(doc).splitlines() + if line_no >= len(lines): + raise EsphomeError(f"{doc}:{line_no + 1} changed since it was read") + return lines[line_no] + + +def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None: + """The file and line ``name:`` was read from, None when validation added + it or a merge key brought it in from an anchor elsewhere. Mapping keys + keep their range through validation, values may not.""" + rng = getattr(next((k for k in mapping if k == name), None), "esp_range", None) + if rng is None: + return None + # An included mapping carries the `!include` line of its parent, so only + # a key in the same document can be placed against the mapping + if (own := getattr(mapping, "esp_range", None)) is None: + return None # a mapping built in code, its keys are not on its lines + if ( + rng.start_mark.document == own.start_mark.document + and not own.start_mark.line <= rng.start_mark.line <= own.end_mark.line + ): + return None + return Path(rng.start_mark.document), rng.start_mark.line + + +def write_keeping_mode(path: Path, text: str, like: Path | None = None) -> None: + """Write with the mode of ``like`` (default: the file itself) rather + than write_file's 0644; a 0600 secrets file stays 0600.""" + try: + mode = stat.S_IMODE((like or path).stat().st_mode) + except OSError as err: + raise EsphomeError(f"Could not read the mode of {like or path}: {err}") from err + try: + write_file(path, text, private=True) + except EsphomeError as err: + # write_file keeps the reason in the cause only + raise EsphomeError(f"{err}: {err.__cause__}") from err + try: + path.chmod(mode) + except OSError as err: + raise EsphomeError( + f"{path} was written but could not get its mode back: {err}" + ) from err + + +def rewritten_text(original: str, edits: list[LineEdit]) -> str: + """``original`` with the edits applied; every edit must still find the + line it was located on.""" + lines = original.splitlines(keepends=True) + for edit in edits: + text = lines[edit.line].rstrip("\r\n") if edit.line < len(lines) else None + if text != edit.old_line: + raise EsphomeError(f"{edit.path}:{edit.line + 1} changed since it was read") + lines[edit.line] = edit.new_line + lines[edit.line][len(text) :] + return "".join(lines) diff --git a/tests/unit_tests/cli/__init__.py b/tests/unit_tests/cli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/cli/test_rename.py b/tests/unit_tests/cli/test_rename.py new file mode 100644 index 00000000000..944d1e37ca9 --- /dev/null +++ b/tests/unit_tests/cli/test_rename.py @@ -0,0 +1,839 @@ +"""Tests for ``esphome rename``.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from pathlib import Path +import sys +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from pytest import CaptureFixture + +from esphome.cli.rename import command_rename +from esphome.const import CONF_ESPHOME, CONF_NAME, CONF_SUBSTITUTIONS +from esphome.core import CORE + + +@dataclass +class MockArgs: + name: str | None = None + dashboard: bool = False + + +def setup_core(tmp_path: Path, config: dict[str, Any] | None = None) -> None: + """Point CORE at a config in ``tmp_path``; the tests override the path.""" + CORE.config = config or {} + CORE.config_path = tmp_path / "test.yaml" + CORE.name = "test" + + +@pytest.fixture +def mock_run_external_process() -> Generator[Mock]: + """The child esphome the command starts to validate and install.""" + with patch("esphome.cli.rename.run_external_process") as mock: + mock.return_value = 0 + yield mock + + +def test_command_rename_invalid_characters( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with invalid characters in name.""" + setup_core(tmp_path=tmp_path) + + # Test with invalid character (space) + args = MockArgs(name="invalid name") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "invalid character" in captured.out.lower() + + +def test_command_rename_complex_yaml( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with complex YAML that cannot be renamed.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + args = MockArgs(name="newname") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "complex yaml" in captured.out.lower() + + +def test_command_rename_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test successful rename of a simple configuration.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s + +wifi: + ssid: "test" + password: "test1234" +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # Simulate successful validation and upload + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify new file was created + new_file = tmp_path / "newname.yaml" + assert new_file.exists() + + # Verify old file was removed + assert not config_file.exists() + + # Verify content was updated + content = new_file.read_text() + assert ( + 'name: "newname"' in content + or "name: 'newname'" in content + or "name: newname" in content + ) + + captured = capfd.readouterr() + assert "SUCCESS" in captured.out + + +def test_command_rename_with_substitutions( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename with substitutions in YAML.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify substitution was updated + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + assert 'device_name: "newname"' in content + + +def test_command_rename_validation_failure( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when validation fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + + args = MockArgs(name="newname", dashboard=False) + + # First call for validation fails + mock_run_external_process.return_value = 1 + + result = command_rename(args, {}) + + assert result == 1 + + # Verify new file was created but then removed due to failure + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Verify old file still exists (not removed on failure) + assert config_file.exists() + + captured = capfd.readouterr() + assert "Rename failed" in captured.out + + +def test_command_rename_install_failure_reverts( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when the install (esphome run) step fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # First call (config validation) succeeds; second (esphome run) fails. + mock_run_external_process.side_effect = [0, 1] + + result = command_rename(args, {}) + + assert result == 1 + + # New file was unlinked when install failed. + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Old file is preserved so the device stays reachable under the + # original hostname. + assert config_file.exists() + + +def test_command_rename_target_exists_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the target filename already exists. + + Without this guard, the rename would overwrite the unrelated + device's YAML and OTA-install our firmware to the wrong device. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + target_file = tmp_path / "newname.yaml" + target_file.write_text(""" +esphome: + name: someoneelse + +esp32: + board: nodemcu-32s +""") + target_original = target_file.read_text() + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + # No subprocess work happened — refusal is up-front. + mock_run_external_process.assert_not_called() + # Target file untouched: same content, still on disk. + assert target_file.exists() + assert target_file.read_text() == target_original + # Source file untouched. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already exists" in captured.out + + +def test_command_rename_same_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new name matches the current name. + + A same-name rename would otherwise re-write the YAML and queue + a redundant compile + install — wasted work the user almost + certainly didn't intend. + """ + config_file = tmp_path / "samename.yaml" + config_file.write_text(""" +esphome: + name: samename + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}} + + args = MockArgs(name="samename", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # File preserved verbatim — no rewrite happened. + assert config_file.exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_friendly_name_substring( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match the ``name:`` substring of ``friendly_name:``. + + Without anchoring the regex at line start, the pattern + ``\s*name:\s+`` could match the trailing ``name:`` + substring inside ``friendly_name: ``. The rewrite would + flip both lines to the new name, leaving the user with a + silently corrupted ``friendly_name``. + """ + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + friendly_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + # esphome.name swapped. + assert 'name: "newname"' in content + # friendly_name kept verbatim. + assert "friendly_name: oldname" in content + + +def test_command_rename_does_not_match_old_name_as_value_prefix( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + r"""Test rename does not match ``old_name`` as a prefix of a longer value. + + With ``old_name = kitchen`` the value ``kitchen2`` (a sensor + or wifi entry) would otherwise match the unanchored + ``["']?kitchen["']?`` pattern at the prefix and get + rewritten to the new name. The end-of-value lookahead keeps + the match restricted to whole tokens. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +wifi: + ap: + ssid: kitchen2 +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert 'name: "garage"' in content + # The wifi ssid value is unrelated and stays intact. + assert "ssid: kitchen2" in content + + +def test_command_rename_same_resolved_name_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when ``new_name`` matches the resolved device name. + + The path-equality check only catches the case where the + config filename matches the device name. For a config whose + filename and ``esphome.name`` differ (here ``weird-file.yaml`` + holds ``esphome.name: kitchen``), running + ``esphome rename weird-file.yaml kitchen`` would otherwise + fall through to the rewrite + install: the YAML's name stays + ``kitchen``, the file is renamed to ``kitchen.yaml``, and the + device gets a redundant flash. Refuse up-front so the + "already the device's name" message matches reality. + """ + config_file = tmp_path / "weird-file.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file untouched, no derived target written. + assert config_file.exists() + assert not (tmp_path / "kitchen.yaml").exists() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_target_path_equals_source_refuses( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename refuses when the new path resolves to the source file. + + Reachable only when the YAML's filename and ``esphome.name`` + disagree — here ``kitchen.yaml`` holds ``esphome.name: garage`` + and the user runs ``esphome rename kitchen.yaml kitchen``. The + name-equality check above passes (``garage != kitchen``), but + ``/kitchen.yaml`` resolves to the source file + itself, so the rewrite would clobber the source mid-rename. + Refuse rather than silently overwriting. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: garage + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}} + + args = MockArgs(name="kitchen", dashboard=False) + + result = command_rename(args, {}) + + assert result == 1 + mock_run_external_process.assert_not_called() + # Source file still present and unmodified. + assert config_file.exists() + assert "name: garage" in config_file.read_text() + + captured = capfd.readouterr() + assert "already" in captured.out.lower() + + +def test_command_rename_does_not_touch_lookalike_name_in_other_blocks( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename only swaps the esphome.name line. + + A device whose name happens to match a sensor's / output's + ``name:`` value must not have those other names rewritten — + they're independent. Without an anchor for the esphome block + a naive regex would clobber every line whose value matches. + """ + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen + +esp32: + board: nodemcu-32s + +sensor: + - platform: template + name: kitchen + lambda: 'return 0;' +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + # esphome.name renamed. + assert 'name: "garage"' in content + # Sensor's name is the user's entity name — must not be touched. + assert " name: kitchen\n" in content + + +def test_command_rename_preserves_trailing_comment( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename preserves a trailing ``# comment`` on the name line.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: kitchen # primary device + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + new_file = tmp_path / "garage.yaml" + content = new_file.read_text() + assert "# primary device" in content + + +def test_command_rename_handles_double_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is double-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: "kitchen" + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_handles_single_quoted_value( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename matches when the existing value is single-quoted.""" + config_file = tmp_path / "kitchen.yaml" + config_file.write_text(""" +esphome: + name: 'kitchen' + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} + + args = MockArgs(name="garage", dashboard=False) + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + new_file = tmp_path / "garage.yaml" + assert 'name: "garage"' in new_file.read_text() + + +def test_command_rename_leaves_a_lookalike_substitution_line_alone( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Only the substitution's own line changes; another block's field of + the same name and value is not it.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +example: + device_name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + content = (tmp_path / "newname.yaml").read_text() + assert 'device_name: "newname"' in content + assert "example:\n device_name: oldname\n" in content + + +def test_command_rename_keeps_line_endings_and_mode( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """A CRLF file stays CRLF and the new file gets the old one's mode.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_bytes( + b"esphome:\r\n name: oldname # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n" + ) + if sys.platform != "win32": + config_file.chmod(0o600) + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + new_file = tmp_path / "newname.yaml" + assert new_file.read_bytes() == ( + b'esphome:\r\n name: "newname" # device\r\n\r\nesp32:\r\n board: nodemcu-32s\r\n' + ) + if sys.platform != "win32": + assert new_file.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.parametrize( + ("yaml_text", "extra"), + [ + ("esphome:\n name: ${missing}\n", {}), + ("esphome: {name: oldname}\n", {}), + ("esphome: !include base.yaml\n", {"base.yaml": "name: oldname\n"}), + ( + ( + "named: &named\n name: oldname\n\nesphome:\n <<: *named\n\n" + "sensor:\n - platform: template\n <<: *named\n" + ), + {}, + ), + ], + ids=["missing_substitution", "flow_mapping", "included_name", "merged_name"], +) +def test_command_rename_refuses_shapes_without_a_plain_name_line( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, + yaml_text: str, + extra: dict[str, str], +) -> None: + """The name line must be a plain value in the file being renamed.""" + for name, text in extra.items(): + (tmp_path / name).write_text(text) + config_file = tmp_path / "oldname.yaml" + config_file.write_text(yaml_text) + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + mock_run_external_process.assert_not_called() + assert "complex yaml" in capfd.readouterr().out.lower() + + +def test_command_rename_removes_the_new_file_when_its_mode_cannot_be_set( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """No orphan is left for the next attempt to trip over.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with patch("pathlib.Path.chmod", side_effect=OSError("read-only share")): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + assert not (tmp_path / "newname.yaml").exists() + mock_run_external_process.assert_not_called() + assert "Rename failed" in capfd.readouterr().out + + +def test_command_rename_refuses_a_name_without_a_source_line( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """A key the loader did not read from a file cannot be located.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with patch("esphome.yaml_edit.source_of", return_value=None): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + mock_run_external_process.assert_not_called() + assert "was not read from" in capfd.readouterr().out + + +def test_command_rename_passes_dashboard_to_the_install( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=True), {}) == 0 + install = mock_run_external_process.call_args_list[-1].args + assert install[-6:-4] == ("--dashboard", "run") + + +def test_command_rename_interrupted_install_reverts( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.side_effect = [0, KeyboardInterrupt] + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + assert not (tmp_path / "newname.yaml").exists() + assert config_file.exists() + + +def test_command_rename_reads_a_config_linked_from_outside( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """The source is only read; the new file lands in the config directory.""" + outside = tmp_path / "elsewhere.yaml" + outside.write_text("esphome:\n name: oldname\n") + config_dir = tmp_path / "config" + config_dir.mkdir() + config_file = config_dir / "oldname.yaml" + config_file.symlink_to(outside) + setup_core(tmp_path=config_dir) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 0 + assert (config_dir / "newname.yaml").read_text() == 'esphome:\n name: "newname"\n' + assert not config_file.exists() + assert outside.exists() + + +def test_command_rename_reports_an_orphan_it_could_not_remove( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """The write failure is the message; a cleanup failure is added to it.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + with ( + patch("pathlib.Path.chmod", side_effect=OSError("read-only share")), + patch("pathlib.Path.unlink", side_effect=OSError("busy")), + ): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "Rename failed" in out + assert "Could not remove" in out and "newname.yaml" in out + + +def test_command_rename_install_failure_says_so( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """The device may already carry the new name; the user is told.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.side_effect = [0, 1] + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "Rename failed: the install did not finish" in out + assert "may already run the new name" in out + + +def test_command_rename_validation_revert_reports_an_orphan( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + config_file = tmp_path / "oldname.yaml" + config_file.write_text("esphome:\n name: oldname\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = config_file + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + mock_run_external_process.return_value = 1 + with patch("pathlib.Path.unlink", side_effect=OSError("busy")): + assert command_rename(MockArgs(name="newname", dashboard=False), {}) == 1 + out = capfd.readouterr().out + assert "does not validate" in out and "Could not remove" in out diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bf5d1566c05..837643417d5 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -42,7 +42,6 @@ from esphome.__main__ import ( command_config_hash, command_dashboard, command_idedata, - command_rename, command_run, command_update_all, command_wizard, @@ -101,7 +100,6 @@ from esphome.const import ( CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, - CONF_SUBSTITUTIONS, CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, @@ -4448,627 +4446,6 @@ def test_command_config_hash( assert output == f"0x{CORE.config_hash:08x}" -def test_command_rename_invalid_characters( - tmp_path: Path, capfd: CaptureFixture[str] -) -> None: - """Test command_rename with invalid characters in name.""" - setup_core(tmp_path=tmp_path) - - # Test with invalid character (space) - args = MockArgs(name="invalid name") - result = command_rename(args, {}) - - assert result == 1 - captured = capfd.readouterr() - assert "invalid character" in captured.out.lower() - - -def test_command_rename_complex_yaml( - tmp_path: Path, capfd: CaptureFixture[str] -) -> None: - """Test command_rename with complex YAML that cannot be renamed.""" - config_file = tmp_path / "test.yaml" - config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - args = MockArgs(name="newname") - result = command_rename(args, {}) - - assert result == 1 - captured = capfd.readouterr() - assert "complex yaml" in captured.out.lower() - - -def test_command_rename_success( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test successful rename of a simple configuration.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s - -wifi: - ssid: "test" - password: "test1234" -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - # Set up CORE.config to avoid ValueError when accessing CORE.address - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - # Simulate successful validation and upload - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - # Verify new file was created - new_file = tmp_path / "newname.yaml" - assert new_file.exists() - - # Verify old file was removed - assert not config_file.exists() - - # Verify content was updated - content = new_file.read_text() - assert ( - 'name: "newname"' in content - or "name: 'newname'" in content - or "name: newname" in content - ) - - captured = capfd.readouterr() - assert "SUCCESS" in captured.out - - -def test_command_rename_with_substitutions( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename with substitutions in YAML.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -substitutions: - device_name: oldname - -esphome: - name: ${device_name} - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - # Set up CORE.config to avoid ValueError when accessing CORE.address - CORE.config = { - CONF_ESPHOME: {CONF_NAME: "oldname"}, - CONF_SUBSTITUTIONS: {"device_name": "oldname"}, - } - - args = MockArgs(name="newname", dashboard=False) - - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - # Verify substitution was updated - new_file = tmp_path / "newname.yaml" - content = new_file.read_text() - assert 'device_name: "newname"' in content - - -def test_command_rename_validation_failure( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename when validation fails.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - - args = MockArgs(name="newname", dashboard=False) - - # First call for validation fails - mock_run_external_process.return_value = 1 - - result = command_rename(args, {}) - - assert result == 1 - - # Verify new file was created but then removed due to failure - new_file = tmp_path / "newname.yaml" - assert not new_file.exists() - - # Verify old file still exists (not removed on failure) - assert config_file.exists() - - captured = capfd.readouterr() - assert "Rename failed" in captured.out - - -def test_command_rename_install_failure_reverts( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename when the install (esphome run) step fails.""" - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - # First call (config validation) succeeds; second (esphome run) fails. - mock_run_external_process.side_effect = [0, 1] - - result = command_rename(args, {}) - - assert result == 1 - - # New file was unlinked when install failed. - new_file = tmp_path / "newname.yaml" - assert not new_file.exists() - - # Old file is preserved so the device stays reachable under the - # original hostname. - assert config_file.exists() - - -def test_command_rename_target_exists_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the target filename already exists. - - Without this guard, the rename would overwrite the unrelated - device's YAML and OTA-install our firmware to the wrong device. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - -esp32: - board: nodemcu-32s -""") - target_file = tmp_path / "newname.yaml" - target_file.write_text(""" -esphome: - name: someoneelse - -esp32: - board: nodemcu-32s -""") - target_original = target_file.read_text() - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - # No subprocess work happened — refusal is up-front. - mock_run_external_process.assert_not_called() - # Target file untouched: same content, still on disk. - assert target_file.exists() - assert target_file.read_text() == target_original - # Source file untouched. - assert config_file.exists() - - captured = capfd.readouterr() - assert "already exists" in captured.out - - -def test_command_rename_same_name_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the new name matches the current name. - - A same-name rename would otherwise re-write the YAML and queue - a redundant compile + install — wasted work the user almost - certainly didn't intend. - """ - config_file = tmp_path / "samename.yaml" - config_file.write_text(""" -esphome: - name: samename - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}} - - args = MockArgs(name="samename", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # File preserved verbatim — no rewrite happened. - assert config_file.exists() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_does_not_touch_friendly_name_substring( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - r"""Test rename does not match the ``name:`` substring of ``friendly_name:``. - - Without anchoring the regex at line start, the pattern - ``\s*name:\s+`` could match the trailing ``name:`` - substring inside ``friendly_name: ``. The rewrite would - flip both lines to the new name, leaving the user with a - silently corrupted ``friendly_name``. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -esphome: - name: oldname - friendly_name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} - - args = MockArgs(name="newname", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "newname.yaml" - content = new_file.read_text() - # esphome.name swapped. - assert 'name: "newname"' in content - # friendly_name kept verbatim. - assert "friendly_name: oldname" in content - - -def test_command_rename_does_not_match_old_name_as_value_prefix( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - r"""Test rename does not match ``old_name`` as a prefix of a longer value. - - With ``old_name = kitchen`` the value ``kitchen2`` (a sensor - or wifi entry) would otherwise match the unanchored - ``["']?kitchen["']?`` pattern at the prefix and get - rewritten to the new name. The end-of-value lookahead keeps - the match restricted to whole tokens. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s - -wifi: - ap: - ssid: kitchen2 -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - assert 'name: "garage"' in content - # The wifi ssid value is unrelated and stays intact. - assert "ssid: kitchen2" in content - - -def test_command_rename_same_resolved_name_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when ``new_name`` matches the resolved device name. - - The path-equality check only catches the case where the - config filename matches the device name. For a config whose - filename and ``esphome.name`` differ (here ``weird-file.yaml`` - holds ``esphome.name: kitchen``), running - ``esphome rename weird-file.yaml kitchen`` would otherwise - fall through to the rewrite + install: the YAML's name stays - ``kitchen``, the file is renamed to ``kitchen.yaml``, and the - device gets a redundant flash. Refuse up-front so the - "already the device's name" message matches reality. - """ - config_file = tmp_path / "weird-file.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="kitchen", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # Source file untouched, no derived target written. - assert config_file.exists() - assert not (tmp_path / "kitchen.yaml").exists() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_target_path_equals_source_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when the new path resolves to the source file. - - Reachable only when the YAML's filename and ``esphome.name`` - disagree — here ``kitchen.yaml`` holds ``esphome.name: garage`` - and the user runs ``esphome rename kitchen.yaml kitchen``. The - name-equality check above passes (``garage != kitchen``), but - ``/kitchen.yaml`` resolves to the source file - itself, so the rewrite would clobber the source mid-rename. - Refuse rather than silently overwriting. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: garage - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}} - - args = MockArgs(name="kitchen", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # Source file still present and unmodified. - assert config_file.exists() - assert "name: garage" in config_file.read_text() - - captured = capfd.readouterr() - assert "already" in captured.out.lower() - - -def test_command_rename_does_not_touch_lookalike_name_in_other_blocks( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename only swaps the esphome.name line. - - A device whose name happens to match a sensor's / output's - ``name:`` value must not have those other names rewritten — - they're independent. Without an anchor for the esphome block - a naive regex would clobber every line whose value matches. - """ - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen - -esp32: - board: nodemcu-32s - -sensor: - - platform: template - name: kitchen - lambda: 'return 0;' -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - # esphome.name renamed. - assert 'name: "garage"' in content - # Sensor's name is the user's entity name — must not be touched. - assert " name: kitchen\n" in content - - -def test_command_rename_preserves_trailing_comment( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename preserves a trailing ``# comment`` on the name line.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: kitchen # primary device - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - - new_file = tmp_path / "garage.yaml" - content = new_file.read_text() - assert "# primary device" in content - - -def test_command_rename_handles_double_quoted_value( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename matches when the existing value is double-quoted.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: "kitchen" - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - assert 'name: "garage"' in new_file.read_text() - - -def test_command_rename_handles_single_quoted_value( - tmp_path: Path, - mock_run_external_process: Mock, -) -> None: - """Test rename matches when the existing value is single-quoted.""" - config_file = tmp_path / "kitchen.yaml" - config_file.write_text(""" -esphome: - name: 'kitchen' - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}} - - args = MockArgs(name="garage", dashboard=False) - mock_run_external_process.return_value = 0 - - result = command_rename(args, {}) - - assert result == 0 - new_file = tmp_path / "garage.yaml" - assert 'name: "garage"' in new_file.read_text() - - -def test_command_rename_too_many_substitution_matches_refuses( - tmp_path: Path, - capfd: CaptureFixture[str], - mock_run_external_process: Mock, -) -> None: - """Test rename refuses when ``${var}`` resolves to multiple matches. - - When ``esphome.name: ${device_name}`` and the substitution - definition ``device_name: foo`` appears more than once in the - YAML (e.g. inside multiple included blocks), the regex rewrite - can't tell which one to flip. Rather than silently picking one - or rewriting both, the command refuses. - """ - config_file = tmp_path / "oldname.yaml" - config_file.write_text(""" -substitutions: - device_name: oldname - -esphome: - name: ${device_name} - -# A copy-pasted block that re-declares the substitution at the -# same indent level - happens when users splice in a packaged -# fragment without renaming the variable. -example: - device_name: oldname - -esp32: - board: nodemcu-32s -""") - setup_core(tmp_path=tmp_path) - CORE.config_path = config_file - CORE.config = { - CONF_ESPHOME: {CONF_NAME: "oldname"}, - CONF_SUBSTITUTIONS: {"device_name": "oldname"}, - } - - args = MockArgs(name="newname", dashboard=False) - - result = command_rename(args, {}) - - assert result == 1 - mock_run_external_process.assert_not_called() - # File untouched. - assert config_file.exists() - assert "device_name: oldname" in config_file.read_text() - - captured = capfd.readouterr() - assert "Too many matches" in captured.out - - def test_command_update_all_path_string_conversion( tmp_path: Path, mock_run_external_process: Mock, @@ -8028,3 +7405,11 @@ def test_command_analyze_memory_host_refuses_before_compiling( command_analyze_memory(MockArgs(), {}) mock_write_cpp.assert_not_called() mock_compile_program.assert_not_called() + + +def test_command_rename_is_dispatched_to_the_cli_module() -> None: + """__main__ keeps a thin wrapper and imports the command when it runs.""" + args = MockArgs(name="newname") + with patch("esphome.cli.rename.command_rename", return_value=7) as run: + assert main.command_rename(args, {}) == 7 + run.assert_called_once_with(args, {}) diff --git a/tests/unit_tests/test_yaml_edit.py b/tests/unit_tests/test_yaml_edit.py new file mode 100644 index 00000000000..31df23dc46c --- /dev/null +++ b/tests/unit_tests/test_yaml_edit.py @@ -0,0 +1,182 @@ +"""Tests for rewriting single lines of a yaml file.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +from esphome import yaml_util +from esphome.config import do_substitution_pass +from esphome.const import CONF_ESPHOME, CONF_NAME +from esphome.core import CORE, EsphomeError +from esphome.yaml_edit import ( + LineEdit, + field_line_re, + line_at, + read_text, + rewrite, + rewritten_text, + source_of, + write_keeping_mode, +) + +YAML = """esphome: + name: kitchen # the device + +wifi: + ssid: kitchen +""" + + +def _setup(tmp_path: Path, yaml_text: str) -> Path: + """Write the yaml, point CORE at it and load it the way read_config does, + so every node carries its source range.""" + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + # Bytes, so Windows does not turn the newlines into CRLF on the way in + CORE.config_path.write_bytes(yaml_text.encode()) + CORE.raw_config = do_substitution_pass(yaml_util.load_yaml(CORE.config_path), None) + return CORE.config_path + + +def _name_edit(new_name: str) -> LineEdit: + doc, line_no = source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) + text = line_at(doc, line_no) + match = field_line_re(CONF_NAME, "kitchen").match(text) + return LineEdit(doc, line_no, text, rewrite(match, new_name)) + + +def test_rewrite_keeps_the_rest_of_the_line(tmp_path: Path) -> None: + """The value changes; indentation, quotes and the comment stay.""" + path = _setup(tmp_path, YAML.replace("name: kitchen", "name: 'kitchen'")) + edit = _name_edit("garage") + assert (edit.line, edit.new_line) == (1, " name: 'garage' # the device") + assert rewritten_text(read_text(path), [edit]) == YAML.replace( + "name: kitchen", "name: 'garage'" + ) + + +def test_rewrite_can_force_quotes() -> None: + match = field_line_re(CONF_NAME, "kitchen").match(" name: kitchen # x") + assert rewrite(match, "garage", quote='"') == ' name: "garage" # x' + + +def test_only_the_located_line_changes(tmp_path: Path) -> None: + """A lookalike `name:` under another block has its own range.""" + yaml_text = YAML + "sensor:\n - platform: template\n name: kitchen\n" + path = _setup(tmp_path, yaml_text) + text = rewritten_text(read_text(path), [_name_edit("garage")]) + assert text.endswith(" name: kitchen\n") + assert " name: garage # the device" in text + + +def test_line_endings_are_kept(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML.replace("\n", "\r\n")) + assert rewritten_text(read_text(path), [_name_edit("garage")]) == YAML.replace( + "\n", "\r\n" + ).replace("name: kitchen", "name: garage") + + +def test_stale_line_is_refused(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML) + edit = _name_edit("garage") + with pytest.raises(EsphomeError, match="changed since it was read"): + rewritten_text(YAML.replace("kitchen #", "pantry #"), [edit]) + with pytest.raises(EsphomeError, match="changed since it was read"): + rewritten_text("esphome:\n", [edit]) + with pytest.raises(EsphomeError, match="changed since it was read"): + line_at(path, 5) + + +def test_a_comment_needs_whitespace_and_a_scalar_is_not_empty() -> None: + """`abc#def` is one value to the loader, and a bare `key:` heads a block.""" + assert field_line_re("key", "abc").match("key: abc#def") is None + assert field_line_re("key").match("key:") is None + assert field_line_re("key").match("key: abc # c")["trail"] == " # c" + assert field_line_re("key", "abc#def").match("key: abc#def") is not None + + +def test_source_of_is_none_for_a_value_validation_added(tmp_path: Path) -> None: + """Only a key read from a file carries a range, and only a mapping read + from a file can place its keys; a mapping built in code cannot.""" + assert source_of({"name": "kitchen"}, "name") is None + _setup(tmp_path, YAML) + loaded_key = next(iter(CORE.raw_config[CONF_ESPHOME])) + assert source_of({loaded_key: "kitchen"}, CONF_NAME) is None + + +def test_source_of_is_none_for_a_merged_key(tmp_path: Path) -> None: + """A key a merge brought in points at the anchor, which other mappings + may merge as well; it is not this mapping's own line.""" + _setup( + tmp_path, + "named: &named\n name: kitchen\n\nesphome:\n <<: *named\n friendly_name: x\n", + ) + assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) is None + assert source_of(CORE.raw_config[CONF_ESPHOME], "friendly_name") == ( + tmp_path / "test.yaml", + 5, + ) + + +def test_source_of_names_the_file_the_loader_read(tmp_path: Path) -> None: + """An include has its own document; a symlink is reported as given.""" + (tmp_path / "base.yaml").write_bytes(b"name: kitchen\n") + _setup(tmp_path, "esphome: !include base.yaml\n") + assert source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME) == ( + tmp_path / "base.yaml", + 0, + ) + target = tmp_path / "shared" / "test.yaml" + target.parent.mkdir() + target.write_bytes(YAML.encode()) + CORE.config_path.unlink() + CORE.config_path.symlink_to(target) + CORE.raw_config = yaml_util.load_yaml(CORE.config_path) + assert _name_edit("garage").path == CORE.config_path + + +@pytest.mark.skipif(sys.platform == "win32", reason="posix file modes") +def test_write_keeps_the_mode_of_the_file_or_another(tmp_path: Path) -> None: + path = _setup(tmp_path, YAML) + path.chmod(0o600) + write_keeping_mode(path, YAML) + assert path.stat().st_mode & 0o777 == 0o600 + other = tmp_path / "other.yaml" + write_keeping_mode(other, YAML, like=path) + assert other.stat().st_mode & 0o777 == 0o600 + + +def test_write_failures_say_which_step_and_why(tmp_path: Path) -> None: + """A missing mode source, a write that fails, and a mode that cannot be + put back after the write are three different situations.""" + from unittest.mock import patch + + path = _setup(tmp_path, YAML) + with pytest.raises(EsphomeError, match="Could not read the mode of .*gone.yaml"): + write_keeping_mode(path, YAML, like=tmp_path / "gone.yaml") + with ( + patch("pathlib.Path.chmod", side_effect=OSError("denied")), + pytest.raises( + EsphomeError, match="was written but could not get its mode back: denied" + ), + ): + write_keeping_mode(path, YAML) + + def refuse(*_args: object, **_kwargs: object) -> None: + raise EsphomeError(f"Could not write file at {path}") from OSError("disk full") + + with ( + patch("esphome.yaml_edit.write_file", side_effect=refuse), + pytest.raises(EsphomeError, match="Could not write file at .*: disk full"), + ): + write_keeping_mode(path, YAML) + + +def test_read_text_reports_a_file_it_cannot_decode(tmp_path: Path) -> None: + path = tmp_path / "latin1.yaml" + path.write_bytes(b"caf\xe9: 1\n") + with pytest.raises(EsphomeError, match="Error reading file"): + read_text(path) From ef0819f983a4af8e3b20c1838b60c7cd3580f306 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 29 Sep 2026 18:12:55 -0500 Subject: [PATCH 730/759] [nfc] Hold NDEF records in a fixed-size list (#19912) Co-authored-by: Claude Fable 5.1 --- esphome/components/nfc/ndef_message.cpp | 2 +- esphome/components/nfc/ndef_message.h | 9 +++++---- esphome/components/pn532/pn532.cpp | 6 ++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index d9135cbdba6..e597b9192d4 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -97,7 +97,7 @@ bool NdefMessage::add_record(std::unique_ptr record) { ESP_LOGE(TAG, "Too many records. Max: %d", MAX_NDEF_RECORDS); return false; } - this->records_.emplace_back(std::move(record)); + this->records_.emplace_next() = std::move(record); return true; } diff --git a/esphome/components/nfc/ndef_message.h b/esphome/components/nfc/ndef_message.h index 0d7699e9d72..25ec7c00c7d 100644 --- a/esphome/components/nfc/ndef_message.h +++ b/esphome/components/nfc/ndef_message.h @@ -13,6 +13,8 @@ namespace esphome::nfc { static constexpr uint8_t MAX_NDEF_RECORDS = 4; +/// The records of one message, owned in place so parsing a message allocates only the records themselves +using NdefRecords = StaticVector, MAX_NDEF_RECORDS>; class NdefMessage { public: @@ -20,13 +22,12 @@ class NdefMessage { NdefMessage(std::span data); NdefMessage(std::vector &data) : NdefMessage(std::span(data)) {} NdefMessage(const NdefMessage &msg) { - records_.reserve(msg.records_.size()); for (const auto &r : msg.records_) { - records_.emplace_back(r->clone()); + records_.emplace_next() = r->clone(); } } - const std::vector> &get_records() { return this->records_; }; + const NdefRecords &get_records() const { return this->records_; }; bool add_record(std::unique_ptr record); bool add_text_record(const std::string &text); @@ -36,7 +37,7 @@ class NdefMessage { std::vector encode(); protected: - std::vector> records_; + NdefRecords records_; }; } // namespace esphome::nfc diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 91593484c87..89392105a24 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -339,8 +339,9 @@ enum PN532ReadReady PN532::read_ready_(bool block) { return READY; } + // the non-blocking call comes from loop(), where the tick's timestamp is already cached if (!this->rd_started_) { - this->rd_start_time_ = millis(); + this->rd_start_time_ = block ? millis() : App.get_loop_component_start_time(); this->rd_started_ = true; } const uint32_t rd_start_time = this->rd_start_time_; @@ -351,7 +352,8 @@ enum PN532ReadReady PN532::read_ready_(bool block) { break; } - if (millis() - rd_start_time > 100) { + const uint32_t now = block ? millis() : App.get_loop_component_start_time(); + if (now - rd_start_time > 100) { ESP_LOGV(TAG, "Timed out waiting for readiness from PN532!"); this->rd_ready_ = TIMEOUT; break; From ed88002c3783465254117c7b5df2c3a9f59b4784 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 02:23:05 +0200 Subject: [PATCH 731/759] [core] Extract native toolchain package archives in parallel (#18840) --- esphome/arduino8266/framework.py | 23 +- esphome/espidf/framework.py | 4 +- esphome/framework_helpers.py | 100 ++++-- esphome/platformio/library.py | 4 +- esphome/platformio/prefetch.py | 9 +- esphome/platformio/registry.py | 155 +++++++++- .../unit_tests/test_arduino8266_framework.py | 51 +--- tests/unit_tests/test_framework_helpers.py | 91 +++++- tests/unit_tests/test_platformio_prefetch.py | 2 +- tests/unit_tests/test_platformio_registry.py | 289 ++++++++++++++++-- 10 files changed, 591 insertions(+), 137 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index a8f8c65c066..b731b746751 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -24,9 +24,10 @@ from esphome.core import EsphomeError, Version from esphome.framework_helpers import str_to_lst_of_str from esphome.platformio.registry import ( Download, + PackageSpec, Resolver, get_systype, - install_package, + install_packages, prefetch_packages, ) @@ -151,14 +152,14 @@ def check_and_install(framework_version: Version) -> InstalledPaths: toolchain_path = get_toolchain_path() # One spec per package: the prefetch and the installs must agree specs = ( - ( + PackageSpec( FRAMEWORK_PACKAGE, release.tag, framework_path, ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, ("cores/esp8266", "tools/sdk", "libraries"), ), - ( + PackageSpec( TOOLCHAIN_PACKAGE, TOOLCHAIN_VERSION, toolchain_path, @@ -174,18 +175,10 @@ def check_and_install(framework_version: Version) -> InstalledPaths: resolvers[FRAMEWORK_PACKAGE] = release.download if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS: resolvers[TOOLCHAIN_PACKAGE] = toolchain_download - # Fetch both archives at once; the installs below verify and extract - prefetch_packages([spec[:4] for spec in specs], downloads_dir, resolvers) - for name, version, dest, mirrors, expect in specs: - install_package( - name, - version, - dest, - mirrors, - downloads_dir, - expect=expect, - resolve=resolvers.get(name), - ) + # Fetch both archives at once; the installs verify and extract them. + # One spec list for both, so the two phases cannot drift. + prefetch_packages(specs, downloads_dir, resolvers) + install_packages(specs, downloads_dir, resolvers) return InstalledPaths( framework=framework_path, toolchain=toolchain_path, ninja=ninja_path ) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 1a4b17efdb3..8c377561ca6 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -34,7 +34,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, tool_version_runs, - warn_prefetch_failures, + warn_batch_failures, ) from esphome.helpers import write_file_if_changed @@ -774,7 +774,7 @@ def _prefetch_idf_tool_archives( for entry in entries ], ) - warn_prefetch_failures(failures) + warn_batch_failures(failures, "Could not prefetch %s: %s") if len(failures) == len(entries): # A systematic fault, not one flaky mirror: the resume # workaround (#17703) is off for this whole install diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index fc2a18a6ecd..86010e30650 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -15,7 +15,7 @@ import threading import time from typing import IO, TYPE_CHECKING -from esphome.helpers import ProgressBar, rmtree +from esphome.helpers import ProgressBar, get_usable_cpu_count, rmtree from esphome.net_retry import ( NETWORK_MAX_ATTEMPTS, http_request, @@ -288,10 +288,25 @@ def _detect_archive_root(names: Iterable[str]) -> str | None: return root if has_descendant else None +def _resolve_progress( + progress: Callable[[float], None] | None, + progress_header: str | None, + has_work: bool, +) -> Callable[[float], None] | None: + """Fraction reporter for an extractor: the caller's callback wins over a + private ``progress_header`` bar.""" + if progress is not None: + return progress + if progress_header and has_work: + return ProgressBar(progress_header).update + return None + + def _tar_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a TAR archive to the specified directory. @@ -306,6 +321,7 @@ def _tar_extract_all( data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header """ import tarfile @@ -364,21 +380,23 @@ def _tar_extract_all( safe_members.append(member) total = len(safe_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) + report = _resolve_progress(progress, progress_header, total > 0) for i, member in enumerate(safe_members, 1): - tar_ref.extract(member, abs_dest) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) + # Named: the default is fully_trusted on 3.12/3.13, data on + # 3.14. The pre-pass drops unsafe members; an escape past it + # gains nothing, since the build runs what these archives hold. + tar_ref.extract(member, abs_dest, filter="fully_trusted") + if report is not None: + report(i / total) + if report is not None: + report(1) def _zip_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a ZIP archive to the specified directory. @@ -387,6 +405,7 @@ def _zip_extract_all( data: File-like object containing the ZIP archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header """ import zipfile @@ -403,9 +422,7 @@ def _zip_extract_all( strip_prefix = f"{strip_root}/" if strip_root is not None else None total = len(all_members) - progress = ( - ProgressBar(progress_header) if progress_header and total > 0 else None - ) + report = _resolve_progress(progress, progress_header, total > 0) for i, member in enumerate(all_members, 1): # 1. Normalize name @@ -438,10 +455,10 @@ def _zip_extract_all( # 6. Extract zip_ref.extract(member, extract_dir) - if progress is not None: - progress.update(i / total) - if progress is not None: - progress.update(1) + if report is not None: + report(i / total) + if report is not None: + report(1) def _rename_with_retry( @@ -472,6 +489,7 @@ def _7z_extract_all( data: io.BufferedIOBase, extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract a 7z archive to the specified directory. @@ -486,6 +504,7 @@ def _7z_extract_all( data: File-like object containing the 7z archive (must be seekable) extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: called with 1.0 on completion; overrides progress_header """ import py7zr @@ -524,19 +543,15 @@ def _7z_extract_all( continue safe_targets.append(raw) - progress = ( - ProgressBar(progress_header) - if progress_header and safe_targets - else None - ) + report = _resolve_progress(progress, progress_header, bool(safe_targets)) if len(safe_targets) == len(all_names): z.extractall(path=staging) else: z.extract(path=staging, targets=safe_targets) - if progress is not None: - progress.update(1) + if report is not None: + report(1) src_root = staging / strip_root if strip_root else staging for item in src_root.iterdir(): @@ -567,6 +582,7 @@ def archive_extract_all( archive: PathType | io.RawIOBase | IO[bytes], extract_dir: PathType = ".", progress_header: str | None = None, + progress: Callable[[float], None] | None = None, ): """ Extract an archive file to the specified directory. @@ -575,6 +591,7 @@ def archive_extract_all( archive: Path to archive file or file-like object extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header + progress: fraction callback (0..1, ends at 1.0); overrides progress_header Raises: TypeError: If archive is not a valid type @@ -605,7 +622,9 @@ def archive_extract_all( break if matched_fct is None: raise ValueError("Unsupported archive format") - matched_fct(archive_ref, extract_dir, progress_header=progress_header) + matched_fct( + archive_ref, extract_dir, progress_header=progress_header, progress=progress + ) def _open_ranged( @@ -769,13 +788,23 @@ def _stream_response_to_file( # hammering the host or the mirrors. BATCH_DOWNLOAD_WORKERS = 4 +# Measured: gz peaks near 2 workers (8 is slower than serial), xz +# plateaus by 4 and holds ~50 MB of dictionary per worker. +BATCH_EXTRACT_WORKERS = 4 + + +def extract_workers(jobs: int | None = None) -> int: + """Worker count for an extraction batch of ``jobs`` archives.""" + workers = min(get_usable_cpu_count(), BATCH_EXTRACT_WORKERS) + return workers if jobs is None else min(workers, jobs) + def run_batch_downloads( header: str, jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]], max_workers: int = BATCH_DOWNLOAD_WORKERS, ) -> list[tuple[str, BaseException]]: - """Run ``(name, size, fetch)`` download jobs concurrently under one bar. + """Run ``(name, size, fetch)`` jobs concurrently under one bar. Each ``fetch(tracker)`` reports absolute byte counts; the bar total is the sum of the sizes. Failures are returned after the bar is done so @@ -1005,15 +1034,26 @@ def resume_fetch_job( return fetch -def warn_prefetch_failures( +def is_expected_fetch_error(err: BaseException) -> bool: + """Download failures the callers degrade on, vs programming errors.""" + from esphome.core import EsphomeError # local import avoids circular dependency + + return isinstance(err, (EsphomeError, OSError)) + + +def warn_batch_failures( failures: list[tuple[str, BaseException]], - message: str = "Could not prefetch %s: %s", + message: str, ) -> None: - """Warn per failed batch-prefetch job; the caller's installer retries them.""" + """Warn per failed batch job, keeping the traceback of unexpected errors.""" for name, err in failures: # failure_reason: a message-less exception must not log blank - _LOGGER.warning(message, name, failure_reason(err)) - _LOGGER.debug("Prefetch failure detail", exc_info=err) + if is_expected_fetch_error(err): + _LOGGER.warning(message, name, failure_reason(err)) + _LOGGER.debug("Failure detail", exc_info=err) + else: + # A programming error must not be reduced to a bare message + _LOGGER.warning(message, name, failure_reason(err), exc_info=err) def download_with_resume( diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index e551d8f1c04..e5e4aa7245f 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -35,7 +35,7 @@ from esphome.framework_helpers import ( failure_reason, rmdir, run_batch_downloads, - warn_prefetch_failures, + warn_batch_failures, ) _LOGGER = logging.getLogger(__name__) @@ -1109,7 +1109,7 @@ def _prefetch_wave( + [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones], ) # The sequential call below retries and raises the real error - warn_prefetch_failures( + warn_batch_failures( failures, "Prefetch of %s failed (retrying sequentially): %s" ) except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index e648192b737..fbb31ae4529 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -37,13 +37,14 @@ from esphome.framework_helpers import ( content_length, discard_partial_download, downloaded_bytes, + extract_workers, failure_reason, resume_fetch_job, run_batch_downloads, wait_for_download_lock, - warn_prefetch_failures, + warn_batch_failures, ) -from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree +from esphome.helpers import get_bool_env, rmtree _LOGGER = logging.getLogger(__name__) @@ -702,7 +703,7 @@ def _preinstall( would hang, not fail). Waves skip dependencies; the installed manifests feed the next wave. Any failure falls back to pio run. """ - workers = min(get_usable_cpu_count(), len(entries)) + workers = extract_workers(len(entries)) # One manager per worker (_install mutates instance state); built # serially because construction rewires the shared manager logger managers: SimpleQueue = SimpleQueue() @@ -891,7 +892,7 @@ def _prefetch(build_dir: Path, env: str) -> None: ) # PlatformIO retries failed packages itself, without resume failures = run_batch_downloads("Downloading PlatformIO packages", jobs) - warn_prefetch_failures(failures) + warn_batch_failures(failures, "Could not prefetch %s: %s") failed_names = {name for name, _ in failures} elif not groups and not unresolved: # Record the no-work run so the parent skips the next spawn. diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index cab536c8da7..326a587fc88 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -18,9 +18,12 @@ from esphome.framework_helpers import ( download_from_mirrors, download_with_resume, downloaded_bytes, + extract_workers, + is_expected_fetch_error, rmdir, run_batch_downloads, wait_for_download_lock, + warn_batch_failures, ) from esphome.net_retry import fetch_with_retry, http_request @@ -174,6 +177,16 @@ def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None: ) +class PackageSpec(NamedTuple): + """One registry package to install.""" + + name: str + version: str + dest: Path + mirrors: list[str] + expect: Collection[str] = () + + class _PendingArchive(NamedTuple): name: str version: str @@ -194,27 +207,47 @@ def is_installed(dest: Path) -> bool: return (dest / ".esphome_extracted").is_file() +def _batched_download_progress( + name: str, version: str, extract_progress: Callable[[float], None] +) -> Callable[[int], None]: + """Zero-tick tracker for a batched install; announces a real download + once, since the shared bar cannot move for it.""" + ticks = 0 + + def progress(done: int) -> None: + nonlocal ticks + ticks += 1 + # A verified archive credits itself in one tick; more than one + # means bytes are streaming, including a resumed .part + if ticks == 2: + _LOGGER.info("Re-downloading %s %s ...", name, version) + extract_progress(0.0) + + return progress + + def prefetch_packages( - packages: list[tuple[str, str, Path, list[str]]], + packages: Collection[PackageSpec], downloads_dir: Path, resolvers: dict[str, Resolver] | None = None, ) -> None: """Download pending package archives in parallel under one combined bar. - ``packages`` holds ``(name, version, dest, mirrors)`` per package; - ``resolvers`` replaces the registry lookup by name. Purely - an optimization: ``install_package`` verifies every archive and - re-downloads anything this pass left unfinished. Mirror overrides and - registry entries without a size stay on the sequential path so its - per-file bars remain trustworthy. Each fetch holds the same per-dest - lock as ``install_package``: the archive's ``.part`` file is shared, and - two concurrent writers would truncate each other's bytes. + ``packages`` holds one ``PackageSpec`` per package, the same list the + install pass takes; ``expect`` is unused here and ``resolvers`` replaces + the registry lookup by name. Purely an optimization: ``install_package`` + verifies every archive and re-downloads anything this pass left + unfinished. Mirror overrides and registry entries without a size stay on + the sequential path so its per-file bars remain trustworthy. Each fetch + holds the same per-dest lock as ``install_package``: the archive's + ``.part`` file is shared, and two concurrent writers would truncate each + other's bytes. """ from filelock import FileLock, Timeout pending: list[_PendingArchive] = [] seen: set[Path] = set() - for name, version, dest, mirrors in packages: + for name, version, dest, mirrors, _expect in packages: if mirrors or is_installed(dest): continue archive = _archive_path(downloads_dir, name, version) @@ -283,7 +316,7 @@ def prefetch_packages( [(entry.name, entry.size, partial(_fetch, entry)) for entry in pending], ) for name, err in failures: - if isinstance(err, (EsphomeError, OSError)): + if is_expected_fetch_error(err): # Expected download failures: install_package retries this one # itself, with a visible bar _LOGGER.debug("Prefetch of %s failed: %s", name, err) @@ -301,6 +334,7 @@ def install_package( downloads_dir: Path, expect: Collection[str], resolve: Resolver | None = None, + extract_progress: Callable[[float], None] | None = None, ) -> None: """Download, verify, and extract one package if not already installed. @@ -309,6 +343,9 @@ def install_package( substitution) is trusted as configured. ``downloads_dir`` holds the archive between runs so an interrupted download resumes. ``resolve`` replaces the registry lookup. + + ``extract_progress`` receives extraction fractions in [0, 1] instead of + the private per-file bars (see ``install_packages``). """ if not expect: # Layout validation before marker.touch() is the only guard against @@ -332,7 +369,10 @@ def install_package( # Persistent location so an interrupted download resumes across runs. downloads_dir.mkdir(parents=True, exist_ok=True) archive = _archive_path(downloads_dir, name, version) - _LOGGER.info("Downloading %s %s ...", name, version) + # Batched runs are announced by the batch header + batched = extract_progress is not None and archive.is_file() + if not batched: + _LOGGER.info("Downloading %s %s ...", name, version) if mirrors: _LOGGER.warning( "Downloading %s from a mirror override; checksum verification " @@ -346,11 +386,96 @@ def install_package( url, sha256, size = ( resolve() if resolve else registry_download(name, version) ) - download_with_resume(url, archive, sha256=sha256, size=size) - _LOGGER.info("Extracting %s ...", name) - archive_extract_all(archive, dest, progress_header="Extracting") + download_with_resume( + url, + archive, + sha256=sha256, + size=size, + # Zero ticks: the shared bar must never run backwards + progress=None + if extract_progress is None + else _batched_download_progress(name, version, extract_progress), + ) + if not batched: + _LOGGER.info("Extracting %s ...", name) + archive_extract_all( + archive, dest, progress_header="Extracting", progress=extract_progress + ) # Validate the layout before recording success, so an unexpected # package is never cached as a working install. _check_layout(name, dest, expect) marker.touch() archive.unlink(missing_ok=True) + + +def install_packages( + specs: Collection[PackageSpec], + downloads_dir: Path, + resolvers: dict[str, Resolver] | None = None, +) -> None: + """Install several packages; prefetched archives extract in parallel under + one shared bar, the rest take the sequential ``install_package`` path. + ``resolvers`` replaces the registry lookup by name; the first failure is + re-raised.""" + resolvers = resolvers or {} + pending: list[tuple[PackageSpec, int]] = [] + rest: list[PackageSpec] = [] + for spec in specs: + name, version, dest, mirrors, _expect = spec + archive = _archive_path(downloads_dir, name, version) + if is_installed(dest) or mirrors: + rest.append(spec) + continue + try: + # Sized, not hashed: install_package still verifies the archive + size = archive.stat().st_size + except FileNotFoundError: + rest.append(spec) + continue + pending.append((spec, size)) + if len(pending) < 2: + # One archive alone gains nothing from a pool + rest = list(specs) + pending = [] + + def _install(spec: PackageSpec, size: int, tracker: Callable[[int], None]) -> None: + name, version, dest, mirrors, expect = spec + install_package( + name, + version, + dest, + mirrors, + downloads_dir, + expect=expect, + resolve=resolvers.get(name), + extract_progress=lambda frac: tracker(int(frac * size)), + ) + + if pending: + workers = extract_workers(len(pending)) + _LOGGER.info( + "Extracting %d package archive(s) with %d worker(s): %s", + len(pending), + workers, + ", ".join(spec.name for spec, _ in pending), + ) + failures = run_batch_downloads( + "Extracting packages", + [(spec[0], size, partial(_install, spec, size)) for spec, size in pending], + max_workers=workers, + ) + if failures: + # The raised exception may not name the package; nothing runs + # behind this pass to redo the work + warn_batch_failures(failures, "Could not install %s: %s") + raise failures[0][1] + for name, version, dest, mirrors, expect in rest: + install_package( + name, + version, + dest, + mirrors, + downloads_dir, + expect=expect, + resolve=resolvers.get(name), + ) diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index a4193b9a748..5ffcea0114a 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -92,16 +92,13 @@ def test_check_and_install_mirror_skips_pinned_toolchain(tmp_path: Path) -> None patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), patch.object(framework, "ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", ["http://f"]), patch.object(framework, "ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", ["http://m"]), - patch.object(framework, "install_package") as mock_install, + patch.object(framework, "install_packages") as mock_install, patch.object(framework, "prefetch_packages") as mock_prefetch, patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), ): framework.check_and_install(cv.Version(3, 1, 2)) assert mock_prefetch.call_args.args[2] == {} - assert [call.kwargs["resolve"] for call in mock_install.call_args_list] == [ - None, - None, - ] + assert mock_install.call_args.args[2] == {} def test_check_and_install_installed_toolchain_on_unsupported_host( @@ -142,7 +139,7 @@ def test_check_and_install_unsupported_host_without_toolchain_raises( def test_check_and_install_returns_paths(tmp_path: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), - patch.object(framework, "install_package") as mock_install, + patch.object(framework, "install_packages") as mock_install, patch.object(framework, "prefetch_packages") as mock_prefetch, patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), ): @@ -150,54 +147,38 @@ def test_check_and_install_returns_paths(tmp_path: Path) -> None: assert paths.framework == tmp_path / "frameworks" / _recommended().tag assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION assert paths.ninja == tmp_path / "ninja" - assert mock_install.call_count == 2 # Full argument pinning: a copy-paste swap between the two near-identical - # calls (mirrors, destination) must not stay green - fw_call, tc_call = mock_install.call_args_list - assert fw_call.args == ( - framework.FRAMEWORK_PACKAGE, - _recommended().tag, - tmp_path / "frameworks" / _recommended().tag, - framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, - tmp_path / "downloads", - ) - assert fw_call.kwargs == { - "expect": ("cores/esp8266", "tools/sdk", "libraries"), - "resolve": _recommended().download, - } - assert tc_call.args == ( - framework.TOOLCHAIN_PACKAGE, - framework.TOOLCHAIN_VERSION, - tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, - framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, - tmp_path / "downloads", - ) - assert tc_call.kwargs == { - "expect": ("bin", "xtensa-lx106-elf"), - "resolve": framework.toolchain_download, - } - # The prefetch sees the same package specs as the installs - assert mock_prefetch.call_args.args == ( - [ + # specs (mirrors, destination) must not stay green + assert mock_install.call_args.args == ( + ( ( framework.FRAMEWORK_PACKAGE, _recommended().tag, tmp_path / "frameworks" / _recommended().tag, framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + ("cores/esp8266", "tools/sdk", "libraries"), ), ( framework.TOOLCHAIN_PACKAGE, framework.TOOLCHAIN_VERSION, tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + ("bin", "xtensa-lx106-elf"), ), - ], + ), tmp_path / "downloads", { framework.FRAMEWORK_PACKAGE: _recommended().download, framework.TOOLCHAIN_PACKAGE: framework.toolchain_download, }, ) + # One spec list feeds both phases, so they cannot drift + assert mock_prefetch.call_args.args == mock_install.call_args.args + # PackageSpec instances, not bare tuples: the batch header reads .name + assert all( + isinstance(spec, framework.PackageSpec) + for spec in mock_install.call_args.args[0] + ) def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index 22b34c9df51..f3b182073f2 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -523,6 +523,17 @@ class TestArchiveExtractAll: archive_extract_all(archive, dest) assert (dest / "file.txt").read_text() == "hi" + def test_progress_callback_passed_through(self, tmp_path: Path) -> None: + """The progress kwarg reaches the dispatched extractor.""" + archive = tmp_path / "test.tar.gz" + archive.write_bytes(_gzip_tar_bytes({"file.txt": b"hello"})) + dest = tmp_path / "out" + dest.mkdir() + fractions: list[float] = [] + archive_extract_all(archive, dest, progress=fractions.append) + assert fractions[-1] == 1 + assert (dest / "file.txt").read_bytes() == b"hello" + def test_invalid_type_raises_type_error(self) -> None: with pytest.raises(TypeError, match="archive must be"): archive_extract_all(42, ".") # type: ignore[arg-type] @@ -1951,6 +1962,19 @@ class TestTarExtractAllBranches: mock_pb.assert_called_once_with("Extracting") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header and ends at 1.0.""" + buf = _make_tar([_reg("a.txt"), _reg("b.txt")], {"a.txt": b"x", "b.txt": b"y"}) + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _tar_extract_all( + buf, tmp_path, progress_header="Extracting", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == sorted(fractions) + assert fractions[-1] == 1 + assert (tmp_path / "a.txt").is_file() + # --------------------------------------------------------------------------- # _zip_extract_all — additional branch coverage @@ -1980,6 +2004,19 @@ class TestZipExtractAllBranches: mock_pb.assert_called_once_with("Unzipping") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header and ends at 1.0.""" + buf = _make_zip([("a.txt", "aaa"), ("b.txt", "bbb")]) + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _zip_extract_all( + buf, tmp_path, progress_header="Unzipping", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == sorted(fractions) + assert fractions[-1] == 1 + assert (tmp_path / "a.txt").is_file() + # --------------------------------------------------------------------------- # _rename_with_retry @@ -2137,6 +2174,20 @@ class TestSevenZipExtractAll: mock_pb.assert_called_once_with("Unpacking 7z") mock_pb.return_value.update.assert_called() + def test_progress_callback_replaces_bar(self, tmp_path: Path) -> None: + """A progress callback wins over progress_header; 7z reports 1.0 once.""" + buf = self._make_7z({"file.txt": b"x"}) + out = tmp_path / "out" + out.mkdir() + fractions: list[float] = [] + with patch("esphome.framework_helpers.ProgressBar") as mock_pb: + _7z_extract_all( + buf, out, progress_header="Unpacking 7z", progress=fractions.append + ) + mock_pb.assert_not_called() + assert fractions == [1] + assert (out / "file.txt").is_file() + def test_absolute_path_in_names_skipped(self, tmp_path: Path) -> None: """Names that resolve as absolute are silently skipped.""" import py7zr @@ -2294,18 +2345,50 @@ def test_resume_fetch_job_threads_tracker(tmp_path: Path) -> None: ) -def test_warn_prefetch_failures_names_each_failure( +def test_warn_batch_failures_names_each_failure( caplog: pytest.LogCaptureFixture, ) -> None: """The shared failure loop warns per job with the failure reason.""" - from esphome.framework_helpers import warn_prefetch_failures + from esphome.framework_helpers import warn_batch_failures - warn_prefetch_failures([("toolchain-x@1", OSError("down"))]) + warn_batch_failures( + [("toolchain-x@1", OSError("down"))], "Could not prefetch %s: %s" + ) assert "Could not prefetch toolchain-x@1: down" in caplog.text - warn_prefetch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s") + warn_batch_failures([("lib", OSError("gone"))], "Prefetch of %s failed: %s") assert "Prefetch of lib failed: gone" in caplog.text +def test_extract_workers_caps_and_clamps() -> None: + """Extraction stops scaling well before high core counts, and a batch + never asks for more workers than it has archives.""" + from esphome.framework_helpers import BATCH_EXTRACT_WORKERS, extract_workers + + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=64): + assert extract_workers() == BATCH_EXTRACT_WORKERS + assert extract_workers(2) == 2 + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=1): + assert extract_workers(8) == 1 + + +def test_warn_batch_failures_unexpected_error_keeps_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """An unexpected error type is not reduced to a bare message; expected + download failures stay message-only at WARNING.""" + from esphome.framework_helpers import warn_batch_failures + + with caplog.at_level(logging.DEBUG): + warn_batch_failures( + [("pkg", TypeError("bad call")), ("lib", OSError("down"))], + "Could not install %s: %s", + ) + warnings = {r.getMessage(): r for r in caplog.records if r.levelname == "WARNING"} + assert warnings["Could not install pkg: bad call"].exc_info is not None + assert warnings["Could not install lib: down"].exc_info is None + assert "Failure detail" in caplog.text + + @pytest.mark.parametrize( ("platform", "input_path", "expected"), [ diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 774493ecf41..ef573767f74 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -1727,7 +1727,7 @@ def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None: barrier.wait() seed = _WaveManager(str(tmp_path)) - with patch.object(pf, "get_usable_cpu_count", return_value=2): + with patch("esphome.framework_helpers.get_usable_cpu_count", return_value=2): pf._preinstall( seed, [ diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 9f1c1408469..c30cdc7d6c8 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -2,8 +2,10 @@ from __future__ import annotations -from contextlib import contextmanager +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager import json +import logging import os from pathlib import Path from unittest.mock import MagicMock, patch @@ -45,7 +47,7 @@ def test_registry_download_resolves_once_per_process() -> None: @pytest.fixture(autouse=True) -def _fresh_registry_cache(): +def _fresh_registry_cache() -> Iterator[None]: # registry_download memoizes per process; tests reuse package names registry.registry_download.cache_clear() yield @@ -112,7 +114,7 @@ def _http_response(text: str) -> MagicMock: return resp -def _registry_response(files: list[dict]): +def _registry_response(files: list[dict]) -> AbstractContextManager[MagicMock]: """Patch the consolidated HTTP path to serve a canned registry response.""" payload = {"versions": [{"name": "1.0.0", "files": files}]} return patch.object( @@ -308,7 +310,11 @@ def test_install_package_downloads_via_registry(tmp_path: Path) -> None: "pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",) ) assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz" - assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42} + assert mock_download.call_args[1] == { + "sha256": "abc123", + "size": 42, + "progress": None, + } def test_install_package_downloads_pinned(tmp_path: Path) -> None: @@ -334,7 +340,11 @@ def test_install_package_downloads_pinned(tmp_path: Path) -> None: ) mock_registry.assert_not_called() assert mock_download.call_args[0][0] == "http://y/pinned.tar.gz" - assert mock_download.call_args[1] == {"sha256": "def456", "size": 7} + assert mock_download.call_args[1] == { + "sha256": "def456", + "size": 7, + "progress": None, + } def test_install_package_mirror_wins_over_pinned(tmp_path: Path) -> None: @@ -546,8 +556,10 @@ def test_registry_download_non_list_system_is_named() -> None: registry.registry_download("pkg", "1.0.0") -def _resolve_for(sizes: dict[str, int | None]): - def resolve(name: str, version: str): +def _resolve_for( + sizes: dict[str, int | None], +) -> Callable[[str, str], tuple[str, str, int | None]]: + def resolve(name: str, version: str) -> tuple[str, str, int | None]: size = sizes[name] if size == -1: raise EsphomeError("registry down") @@ -567,8 +579,8 @@ def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -595,8 +607,8 @@ def test_prefetch_packages_uses_pinned_download(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ("payload",)), + ("b", "2.0", tmp_path / "b", [], ("payload",)), ], tmp_path / "dl", {"b": lambda: registry.Download("http://y/b.tar.gz", "def456", 20)}, @@ -626,7 +638,7 @@ def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: registry, "registry_download", side_effect=_resolve_for({"a": 10}) ), ): - registry.prefetch_packages([("a", "1.0", dest, [])], tmp_path / "dl") + registry.prefetch_packages([("a", "1.0", dest, [], ())], tmp_path / "dl") mock_download.assert_not_called() @@ -666,7 +678,7 @@ def test_prefetch_packages_waits_with_the_holders_progress( ), ): registry.prefetch_packages( - [("a", "1.0", dest, []), ("b", "2.0", tmp_path / "b", [])], + [("a", "1.0", dest, [], ()), ("b", "2.0", tmp_path / "b", [], ())], tmp_path / "dl", ) assert ticks == [0, 3, 10, 10] @@ -687,7 +699,10 @@ def test_prefetch_packages_leaves_a_long_held_lock_to_its_holder( ), ): registry.prefetch_packages( - [("a", "1.0", tmp_path / "a", []), ("b", "2.0", tmp_path / "b", [])], + [ + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), + ], tmp_path / "dl", ) mock_download.assert_not_called() @@ -713,8 +728,8 @@ def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("a", "1.0", tmp_path / "a", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("a", "1.0", tmp_path / "a", [], ()), ], tmp_path / "dl", ) @@ -735,8 +750,8 @@ def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", marker_dest, []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", marker_dest, [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -758,9 +773,9 @@ def test_prefetch_packages_mirror_and_sizeless_stay_sequential( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]), - ("b", "2.0", tmp_path / "b", []), - ("c", "3.0", tmp_path / "c", []), + ("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"], ()), + ("b", "2.0", tmp_path / "b", [], ()), + ("c", "3.0", tmp_path / "c", [], ()), ], tmp_path / "dl", ) @@ -781,8 +796,8 @@ def test_prefetch_packages_resolve_failure_defers_to_install( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -803,8 +818,8 @@ def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None: ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], dl, ) @@ -826,8 +841,8 @@ def test_prefetch_packages_download_failure_is_debug( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) @@ -851,9 +866,225 @@ def test_prefetch_packages_unexpected_failure_warns( ): registry.prefetch_packages( [ - ("a", "1.0", tmp_path / "a", []), - ("b", "2.0", tmp_path / "b", []), + ("a", "1.0", tmp_path / "a", [], ()), + ("b", "2.0", tmp_path / "b", [], ()), ], tmp_path / "dl", ) assert "TypeError" in caplog.text + + +def _spec( + name: str, + version: str, + dest: Path, + mirrors: list[str] | None = None, + expect: tuple[str, ...] = ("payload",), +) -> registry.PackageSpec: + return registry.PackageSpec(name, version, dest, mirrors or [], expect) + + +def test_install_packages_extracts_verified_archives_in_parallel( + tmp_path: Path, +) -> None: + """Two prefetched archives install concurrently under one shared bar.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x" * 10) + (dl / "b-2.0").write_bytes(b"y" * 20) + with patch.object(registry, "install_package") as mock_install: + registry.install_packages( + [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl + ) + assert mock_install.call_count == 2 + calls = sorted(mock_install.call_args_list, key=lambda c: c[0][0]) + for c, (name, version) in zip(calls, [("a", "1.0"), ("b", "2.0")], strict=True): + assert c[0][:3] == (name, version, tmp_path / name) + assert c[1]["expect"] == ("payload",) + assert callable(c[1]["extract_progress"]) + # Driving the tracker exercises the fraction-to-bytes scaling + c[1]["extract_progress"](0.5) + c[1]["extract_progress"](1.0) + + +def test_install_packages_single_archive_stays_sequential(tmp_path: Path) -> None: + """One verified archive has nothing to parallelize; original order kept.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x") + specs = [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")] + with patch.object(registry, "install_package") as mock_install: + registry.install_packages(specs, dl) + assert [c[0][0] for c in mock_install.call_args_list] == ["a", "b"] + for c in mock_install.call_args_list: + assert "extract_progress" not in c[1] + + +def test_batched_download_progress_announces_a_real_download_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """A batched archive that fails verification streams again behind a bar + that cannot move, so it says so once; a verified archive credits itself + in one tick and stays quiet.""" + ticks: list[float] = [] + with caplog.at_level(logging.INFO): + progress = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + progress(0) + progress(4096) + assert caplog.text.count("Re-downloading pkg 1.0.0") == 1 + # The shared bar never moves for a download; it tracks extraction + assert ticks == [0.0, 0.0] + + # A resumed .part starts mid-file, so the first tick is not zero + caplog.clear() + ticks.clear() + with caplog.at_level(logging.INFO): + resumed = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + resumed(8192) + resumed(16384) + assert caplog.text.count("Re-downloading pkg 1.0.0") == 1 + + caplog.clear() + ticks.clear() + with caplog.at_level(logging.INFO): + verified = registry._batched_download_progress("pkg", "1.0.0", ticks.append) + verified(42) + assert "Re-downloading" not in caplog.text + assert ticks == [0.0] + + +def test_install_packages_no_batch_logs_no_header( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The batch header must not describe a batch that never ran.""" + dl = tmp_path / "dl" + dl.mkdir() + specs = [_spec("a", "1.0", tmp_path / "a")] + with ( + caplog.at_level(logging.INFO), + patch.object(registry, "install_package"), + ): + registry.install_packages(specs, dl) + assert "Extracting 0" not in caplog.text + assert "package archive(s) with" not in caplog.text + + +def test_install_packages_mirror_and_marker_stay_sequential(tmp_path: Path) -> None: + """Mirror overrides and marker hits never enter the parallel batch.""" + dl = tmp_path / "dl" + dl.mkdir() + for name, ver in (("a", "1.0"), ("b", "2.0"), ("c", "3.0"), ("d", "4.0")): + (dl / f"{name}-{ver}").write_bytes(b"x") + marked = tmp_path / "c" + marked.mkdir() + (marked / ".esphome_extracted").touch() + specs = [ + _spec("a", "1.0", tmp_path / "a"), + _spec("b", "2.0", tmp_path / "b", mirrors=["http://m"]), + _spec("c", "3.0", marked), + _spec("d", "4.0", tmp_path / "d"), + ] + with patch.object(registry, "install_package") as mock_install: + registry.install_packages(specs, dl) + sequential = [ + c for c in mock_install.call_args_list if "extract_progress" not in c[1] + ] + batched = [c for c in mock_install.call_args_list if "extract_progress" in c[1]] + assert sorted(c[0][0] for c in sequential) == ["b", "c"] + assert sorted(c[0][0] for c in batched) == ["a", "d"] + + +def test_install_packages_first_failure_reraised( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Installs are mandatory: the first failure propagates, extras are logged.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x") + (dl / "b-2.0").write_bytes(b"y") + boom = EsphomeError("bad layout") + + def _fail(name: str, *_a, **_kw) -> None: + raise boom if name == "a" else EsphomeError("also bad") + + with ( + patch.object(registry, "install_package", side_effect=_fail), + pytest.raises(EsphomeError), + ): + registry.install_packages( + [_spec("a", "1.0", tmp_path / "a"), _spec("b", "2.0", tmp_path / "b")], dl + ) + # Every failure is named, including the re-raised one: its exception + # message may not identify the package + assert "Could not install a" in caplog.text + assert "Could not install b" in caplog.text + + +@contextmanager +def _batched_install( + tmp_path: Path, + extract_progress: Callable[[float], None] | None, + prefill_archive: bool = True, +) -> Iterator[tuple[MagicMock, MagicMock]]: + """Run a batched install_package of pkg@1.0.0; yields the download and + extract mocks.""" + dest = tmp_path / "pkg" + if prefill_archive: + (tmp_path / "dl").mkdir() + (tmp_path / "dl" / "pkg-1.0.0").write_bytes(b"x") + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object( + registry, + "registry_download", + return_value=("http://x/pkg.tar.gz", "abc123", 42), + ), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", + "1.0.0", + dest, + [], + tmp_path / "dl", + expect=("payload",), + extract_progress=extract_progress, + ) + yield mock_download, mock_extract + + +def test_install_package_extract_progress_suppresses_bars( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A batched install routes extraction fractions to the caller and keeps + both private bars and per-package INFO lines off the shared bar.""" + fractions: list[float] = [] + with ( + caplog.at_level(logging.INFO), + _batched_install(tmp_path, fractions.append) as (mock_download, mock_extract), + ): + pass + assert mock_extract.call_args[1]["progress"] == fractions.append + # The download tracker reports zero bytes, keeping the shared bar honest + download_progress = mock_download.call_args[1]["progress"] + assert callable(download_progress) + download_progress(42) + assert fractions == [0.0] + assert "Downloading pkg" not in caplog.text + assert "Extracting pkg" not in caplog.text + + +def test_install_package_batched_missing_archive_keeps_info_log( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A batched archive that unexpectedly needs a real download keeps the + INFO line; the shared bar shows no progress for it.""" + with ( + caplog.at_level(logging.INFO), + _batched_install(tmp_path, lambda _frac: None, prefill_archive=False), + ): + pass + assert "Downloading pkg 1.0.0" in caplog.text From ac1190094a22c91d4ea6109c329355bb69b6974a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:48:10 +0100 Subject: [PATCH 732/759] [ethernet] Add KSZ8851SNL SPI ethernet support (#19883) --- esphome/components/ethernet/__init__.py | 12 +- .../components/ethernet/ethernet_component.h | 1 + .../ethernet/ethernet_component_esp32.cpp | 21 ++++ esphome/core/defines.h | 2 + .../ethernet/config/ksz8851snl_idf5.yaml | 16 +++ .../ethernet/config/ksz8851snl_idf6.yaml | 18 +++ .../ethernet/test_ksz8851snl.py | 105 ++++++++++++++++++ .../ethernet/common-ksz8851snl.yaml | 19 ++++ .../ethernet/test-ksz8851snl.esp32-idf.yaml | 2 + 9 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml create mode 100644 tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml create mode 100644 tests/component_tests/ethernet/test_ksz8851snl.py create mode 100644 tests/components/ethernet/common-ksz8851snl.yaml create mode 100644 tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 920f2e5e4bf..ce7206bf57a 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -138,6 +138,7 @@ ETHERNET_TYPES = { "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, "YT8531": EthernetType.ETHERNET_TYPE_YT8531, "CH390": EthernetType.ETHERNET_TYPE_CH390, + "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, } # PHY types that need compile-time defines for conditional compilation @@ -160,6 +161,7 @@ _PHY_TYPE_TO_DEFINE = { "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", "CH390": "USE_ETHERNET_CH390", + "KSZ8851SNL": "USE_ETHERNET_KSZ8851SNL", } @@ -184,13 +186,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), + "KSZ8851SNL": IDFRegistryComponent("espressif/ksz8851snl", "1.2.0"), } # These types are always external IDF components (never built-in to ESP-IDF) _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) -SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390", "KSZ8851SNL"} # RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole # RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the # comment above is about ESP-IDF driver coverage, not the RP2 platform. @@ -534,6 +537,12 @@ SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) # https://www.wch-ic.com/downloads/CH390DS1_PDF.html SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) +# The KSZ8851SNL rates fSCLK at 40 MHz maximum, so the shared 80 MHz ceiling is +# out of spec while the 26.67 MHz default is not. KSZ8851SNL/SNLI datasheet +# DS00002381C, table 7-1: +# https://ww1.microchip.com/downloads/aemDocuments/documents/UNG/ProductDocuments/DataSheets/KSZ8851SNL-Single-Port-Ethernet-Controller-with-SPI-DS00002381C.pdf +SPI_SCHEMA_KSZ8851SNL = _spi_schema(max_clock=int(40e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -549,6 +558,7 @@ CONFIG_SCHEMA = cv.All( "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "CH390": SPI_SCHEMA_CH390, + "KSZ8851SNL": SPI_SCHEMA_KSZ8851SNL, "ENC28J60": SPI_SCHEMA_ENC28J60, "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2b67b9093b7..75dcfb45f66 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -92,6 +92,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, ETHERNET_TYPE_CH390, + ETHERNET_TYPE_KSZ8851SNL, }; struct ManualIP { diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index ea732e84ff9..d15ef5a34c6 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -41,6 +41,10 @@ #include "esp_eth_mac_dm9051.h" #include "esp_eth_phy_dm9051.h" #endif +#ifdef USE_ETHERNET_KSZ8851SNL +#include "esp_eth_mac_ksz8851snl.h" +#include "esp_eth_phy_ksz8851snl.h" +#endif #endif // ESP_IDF_VERSION >= 6.0.0 // LAN867x header exists on all IDF versions (external component since IDF 5.3) @@ -264,6 +268,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_CH390) eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_KSZ8851SNL) + eth_ksz8851snl_config_t ksz8851snl_config = ETH_KSZ8851SNL_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -290,6 +296,11 @@ void EthernetComponent::ethernet_lazy_init_() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT ch390_config.poll_period_ms = this->polling_interval_; #endif +#elif defined(USE_ETHERNET_KSZ8851SNL) + ksz8851snl_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ksz8851snl_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -420,6 +431,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_ch390(&phy_config); break; } +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: { + mac = esp_eth_mac_new_ksz8851snl(&ksz8851snl_config, &mac_config); + this->phy_ = esp_eth_phy_new_ksz8851snl(&phy_config); + break; + } #endif #endif default: { @@ -593,6 +610,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_CH390: eth_type = "CH390"; break; +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: + eth_type = "KSZ8851SNL"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6ee6f97947d..f845154389b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -473,8 +473,10 @@ #define USE_ETHERNET_W6300 #define USE_ETHERNET_DM9051 #define USE_ETHERNET_CH390 +#define USE_ETHERNET_KSZ8851SNL #define CONFIG_ETH_SPI_ETHERNET_W5500 1 #define CONFIG_ETH_SPI_ETHERNET_DM9051 1 +#define CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL 1 #define CONFIG_ETH_USE_ESP32_EMAC 1 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml new file mode 100644 index 00000000000..7c8f3336844 --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + id: eth_component + type: KSZ8851SNL + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml new file mode 100644 index 00000000000..bc11c6db5b8 --- /dev/null +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + version: 6.0.0 + +ethernet: + id: eth_component + type: KSZ8851SNL + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + cs_pin: GPIO5 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_ksz8851snl.py b/tests/component_tests/ethernet/test_ksz8851snl.py new file mode 100644 index 00000000000..ca150100888 --- /dev/null +++ b/tests/component_tests/ethernet/test_ksz8851snl.py @@ -0,0 +1,105 @@ +"""Tests for the KSZ8851SNL SPI ethernet type.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_IDF_VERSION, + KEY_VARIANT, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_COMPONENTS, + KEY_ESP32, + KEY_REF, + KEY_SDKCONFIG_OPTIONS, +) +from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + +_BASE_CONFIG = { + "type": "KSZ8851SNL", + "clk_pin": 47, + "mosi_pin": 48, + "miso_pin": 14, + "cs_pin": 21, +} + +_SDKCONFIG_OPTION = "CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL" +_IDF_COMPONENT = "espressif/ksz8851snl" + + +def _sdkconfig() -> dict[str, object]: + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + +def _idf_components() -> dict[str, dict[str, str | None]]: + return CORE.data[KEY_ESP32][KEY_COMPONENTS] + + +def test_codegen_on_idf5_wires_the_spi_driver( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """On IDF 5.x the driver is built into esp_eth and enabled by its Kconfig option.""" + main_cpp = generate_main(component_config_path("ksz8851snl_idf5.yaml")) + + assert "eth_component->set_type(ethernet::ETHERNET_TYPE_KSZ8851SNL);" in main_cpp + assert "eth_component->set_cs_pin(5);" in main_cpp + assert "eth_component->set_interrupt_pin(36);" in main_cpp + assert "USE_ETHERNET_KSZ8851SNL" in {d.name for d in CORE.defines} + assert _sdkconfig()[_SDKCONFIG_OPTION] is True + # Built into IDF 5.x, so the registry component must not be pulled in. + assert _IDF_COMPONENT not in _idf_components() + + +def test_codegen_on_idf6_pulls_the_registry_component( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """IDF 6.0 dropped the built-in driver, so the managed component is required.""" + generate_main(component_config_path("ksz8851snl_idf6.yaml")) + + assert _idf_components()[_IDF_COMPONENT][KEY_REF] == "1.2.0" + # The Kconfig option no longer exists on IDF 6.0. + assert _SDKCONFIG_OPTION not in _sdkconfig() + + +def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + # _validate derives use_address from the node name, which has no default here. + CORE.name = "ksz8851snl-test" + + +@pytest.mark.parametrize("clock_speed", ["26.67MHz", "40MHz"]) +def test_accepts_clock_speed_up_to_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, clock_speed: str +) -> None: + """The datasheet rates fSCLK to 40MHz, so the whole range must be accepted.""" + _set_esp32_s3(set_core_config) + config = CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: clock_speed}) + assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed) + + +def test_rejects_clock_speed_above_the_datasheet_maximum( + set_core_config: SetCoreConfigCallable, +) -> None: + """The shared 80MHz ceiling is out of spec for this part.""" + _set_esp32_s3(set_core_config) + with pytest.raises(Invalid, match="value must be at most 40000000"): + CONFIG_SCHEMA({**_BASE_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) diff --git a/tests/components/ethernet/common-ksz8851snl.yaml b/tests/components/ethernet/common-ksz8851snl.yaml new file mode 100644 index 00000000000..aed9b744dcb --- /dev/null +++ b/tests/components/ethernet/common-ksz8851snl.yaml @@ -0,0 +1,19 @@ +ethernet: + type: KSZ8851SNL + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml new file mode 100644 index 00000000000..4b46dc8f538 --- /dev/null +++ b/tests/components/ethernet/test-ksz8851snl.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ethernet: !include common-ksz8851snl.yaml From 7d904b6330df5f7e47e0aef4a079c5e8e80523f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 03:31:39 +0200 Subject: [PATCH 733/759] [ens160_base] Use the sub_* helpers for optional child entities (#19921) --- esphome/components/ens160_base/__init__.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 1bdfb0c0a6b..6da0d10af90 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -63,15 +63,10 @@ async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_co2(sens)) - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) - if aqi_config := config.get(CONF_AQI): - sens = await sensor.new_sensor(aqi_config) - cg.add(var.set_aqi(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_AQI, var.set_aqi) if compensation_config := config.get(CONF_COMPENSATION): sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) From 203a7f70a375391cec2ebe2c63b0b2f1d170f139 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 03:31:50 +0200 Subject: [PATCH 734/759] [emc2101] Use the sub_* helpers for optional child entities (#19920) --- esphome/components/emc2101/sensor/__init__.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/esphome/components/emc2101/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index cc8901cf389..8c6d469fb82 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -55,22 +55,12 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_EMC2101_ID]) - var = cg.new_Pvariable(config[CONF_ID], paren) + hub = await cg.get_variable(config[CONF_EMC2101_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) await cg.register_component(var, config) - if CONF_INTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_INTERNAL_TEMPERATURE]) - cg.add(var.set_internal_temperature_sensor(sens)) - - if CONF_EXTERNAL_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE]) - cg.add(var.set_external_temperature_sensor(sens)) - - if CONF_SPEED in config: - sens = await sensor.new_sensor(config[CONF_SPEED]) - cg.add(var.set_speed_sensor(sens)) - - if CONF_DUTY_CYCLE in config: - sens = await sensor.new_sensor(config[CONF_DUTY_CYCLE]) - cg.add(var.set_duty_cycle_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) + await sensors(CONF_SPEED, var.set_speed_sensor) + await sensors(CONF_DUTY_CYCLE, var.set_duty_cycle_sensor) From 83d689d944b7c4b7c5eb4ee78b2e55649f027bc4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 03:32:02 +0200 Subject: [PATCH 735/759] [xiaomi_hhccjcy10] Use the sub_* helpers for optional child entities (#19919) --- esphome/components/xiaomi_hhccjcy10/sensor.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index d1ce8dfcf77..c5d256db1d3 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -81,18 +81,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if moisture_config := config.get(CONF_MOISTURE): - sens = await sensor.new_sensor(moisture_config) - cg.add(var.set_moisture(sens)) - if illuminance_config := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(illuminance_config) - cg.add(var.set_illuminance(sens)) - if conductivity_config := config.get(CONF_CONDUCTIVITY): - sens = await sensor.new_sensor(conductivity_config) - cg.add(var.set_conductivity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) From ef4d06e0ea6837411d8d8e0b5c0686cc428b4213 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 03:32:13 +0200 Subject: [PATCH 736/759] [ld6002b] Use the sub_* helpers for optional child entities (#19918) --- esphome/components/ld6002b/binary_sensor.py | 5 ++--- esphome/components/ld6002b/sensor.py | 10 +++------- esphome/components/ld6002b/text_sensor.py | 9 +++------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py index 74095d5dede..36405f06602 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -40,9 +40,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - if target_config := config.get(CONF_TARGET): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_presence_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_TARGET, hub.set_presence_binary_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index cceefb3837a..5653f8b9449 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -154,13 +154,9 @@ CONFIG_SCHEMA = ( 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): - sens = await sensor.new_sensor(target_count_config) - cg.add(hub.set_target_count_sensor(sens)) - - if point_count_config := config.get(CONF_POINT_COUNT): - sens = await sensor.new_sensor(point_count_config) - cg.add(hub.set_point_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_POINT_COUNT, hub.set_point_count_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py index 0e8e2e80e7e..de3dde15cca 100644 --- a/esphome/components/ld6002b/text_sensor.py +++ b/esphome/components/ld6002b/text_sensor.py @@ -24,9 +24,6 @@ CONFIG_SCHEMA = cv.Schema( 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) - cg.add(hub.set_work_mode_text_sensor(sens)) - if ota_config := config.get(CONF_OTA_VERSION): - sens = await text_sensor.new_text_sensor(ota_config) - cg.add(hub.set_ota_version_text_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_WORK_MODE, hub.set_work_mode_text_sensor) + await text_sensors(CONF_OTA_VERSION, hub.set_ota_version_text_sensor) From 23afdc1d9b6573207bec3496fbc6c05da2af15d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 03:32:45 +0200 Subject: [PATCH 737/759] [bme680] Use the sub_* helpers for optional child entities (#19917) --- esphome/components/bme680/sensor.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index dce5c88cfaa..ffe5c74bbd4 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -131,24 +131,15 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) - - if gas_resistance_config := config.get(CONF_GAS_RESISTANCE): - sens = await sensor.new_sensor(gas_resistance_config) - cg.add(var.set_gas_resistance_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) + await sensors(CONF_GAS_RESISTANCE, var.set_gas_resistance_sensor) cg.add(var.set_iir_filter(IIR_FILTER_OPTIONS[config[CONF_IIR_FILTER]])) From cd0a0f3fcafb9155e29324cd83642b749a52e008 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:48:08 +0200 Subject: [PATCH 738/759] [zigbee] Add cluster: device_class to expose ESP32 sensors via measurement clusters instead of Analog/Binary Input (#17388) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/zigbee/__init__.py | 28 +- esphome/components/zigbee/const.py | 1 + esphome/components/zigbee/const_esp32.py | 25 +- .../zigbee/zigbee_attribute_esp32.cpp | 3 +- .../zigbee/zigbee_attribute_esp32.h | 42 ++- esphome/components/zigbee/zigbee_ep_esp32.py | 273 ++++++++++++++++-- esphome/components/zigbee/zigbee_esp32.cpp | 11 +- esphome/components/zigbee/zigbee_esp32.py | 142 ++++++--- .../components/zigbee/zigbee_helpers_esp32.c | 62 ++++ .../components/zigbee/zigbee_helpers_esp32.h | 1 + esphome/components/zigbee/zigbee_zephyr.py | 12 +- tests/components/zigbee/common_esp32.yaml | 29 +- .../zigbee/validate.esp32-c6-idf.yaml | 36 +++ 13 files changed, 565 insertions(+), 100 deletions(-) create mode 100644 tests/components/zigbee/validate.esp32-c6-idf.yaml diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1fa6110f04b..e9fef59f353 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -18,6 +18,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_CLUSTER, CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_ON_JOIN, @@ -74,6 +75,11 @@ BASE_SCHEMA = cv.Schema( _check_report_deprecation, cv.enum(REPORT, lower=True), ), + cv.OnlyWith(CONF_CLUSTER, ["esp32", "zigbee"], default="generic"): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.one_of(*["generic", "device_class"], lower=True), + ), cv.Optional(CONF_ENDPOINT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), @@ -196,21 +202,27 @@ async def to_code(config: ConfigType) -> None: async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: - from .zigbee_zephyr import zephyr_setup_binary_sensor - - await zephyr_setup_binary_sensor(entity, config) + if not config.get(CONF_ZIGBEE_ID): + return + from .zigbee_zephyr import add_binary_sensor + else: + from .zigbee_esp32 import add_component as add_binary_sensor + CORE.add_job(add_binary_sensor, entity, config) async def setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return if CORE.using_zephyr: - from .zigbee_zephyr import zephyr_setup_sensor - - await zephyr_setup_sensor(entity, config) + if not config.get(CONF_ZIGBEE_ID): + return + from .zigbee_zephyr import add_sensor + else: + from .zigbee_esp32 import add_component as add_sensor + CORE.add_job(add_sensor, entity, config) async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None: diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index d922ae372f2..d6abd9d6c07 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -64,6 +64,7 @@ REPORT = { "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_CLUSTER = "cluster" CONF_ENDPOINT = "endpoint" CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 2e1b09fb221..32ae3e37348 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -1,12 +1,14 @@ import esphome.codegen as cg +ALLOWED_UNITS = "allowed_units" DEVICE_TYPE = "device_type" ROLE = "role" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_CLUSTER = "cluster" -SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" +KEY_ZIGBEE_ATTRIBUTES = "zigbee_attributes" +CONNECT = "connect" +SCALE = "scale" KEY_ZIGBEE_EP = "zigbee_ep" KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl" @@ -14,6 +16,11 @@ KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl" DEVICE_ID = { "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "LIGHT_SENSOR": cg.RawExpression("EZB_ZHA_LIGHT_SENSOR_DEVICE_ID"), + "OCCUPANCY_SENSOR": cg.RawExpression("EZB_ZHA_OCCUPANCY_SENSOR_DEVICE_ID"), + "TEMPERATURE_SENSOR": cg.RawExpression("EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID"), + "PRESSURE_SENSOR": cg.RawExpression("EZB_ZHA_PRESSURE_SENSOR_DEVICE_ID"), + "FLOW_SENSOR": cg.RawExpression("EZB_ZHA_FLOW_SENSOR_DEVICE_ID"), "CUSTOM_ATTR": 0xFFF2, } cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") @@ -22,6 +29,14 @@ CLUSTER_ID = { "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, + "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, + "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, + "FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT, + "REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, + "OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING, + "CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, + "PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT, } CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), @@ -31,6 +46,12 @@ attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8, + "UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16, + "INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8, + "INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16, + "ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8, + "ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16, "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 1fb8d1abe44..acf0321b12b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -23,7 +23,8 @@ void ZigbeeAttribute::set_attr_() { } // Check for error if (state != EZB_ZCL_STATUS_SUCCESS) { - ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); + ESP_LOGE(TAG, "Setting attribute 0x%04X on cluster 0x%04X failed, ZCL status: %u", this->attr_id_, + this->cluster_id_, static_cast(state)); } esp_zigbee_lock_release(); } diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index fc229b4e950..47f460406c9 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esphome/core/automation.h" @@ -47,6 +49,7 @@ class ZigbeeAttribute final : public Component { void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); + template void connect(sensor::Sensor *sensor, F &&f); #endif #ifdef USE_BINARY_SENSOR template void connect(binary_sensor::BinarySensor *sensor); @@ -68,6 +71,8 @@ class ZigbeeAttribute final : public Component { bool set_attr_requested_{false}; bool report_requested_{false}; bool force_report_{false}; + template T scale_value_(float value); + template T invalid_value_(); }; template void ZigbeeAttribute::add_attr(T value) { @@ -85,9 +90,44 @@ template void ZigbeeAttribute::set_attr(const T &value) { this->enable_loop(); } +template T ZigbeeAttribute::scale_value_(float value) { + static_assert(sizeof(T) <= 2 || std::is_floating_point_v); + if constexpr (std::is_integral::value) { + const float scaled = this->scale_ * value; + if (std::isnan(value) || scaled < static_cast(std::numeric_limits::lowest()) || + scaled > static_cast(std::numeric_limits::max())) { + return this->invalid_value_(); // 0x8000 / 0xFFFF / 0 for bitmaps + } + return static_cast(lroundf(scaled)); + } + return static_cast(this->scale_ * value); +} + +template T ZigbeeAttribute::invalid_value_() { + if constexpr (std::is_integral_v) { + if constexpr (std::is_signed_v) { + // For signed integer types, NaN is represented by the minimum value + return static_cast(std::numeric_limits::min()); + } + + if (this->attr_type_ >= EZB_ZCL_ATTR_TYPE_UINT8 && this->attr_type_ <= EZB_ZCL_ATTR_TYPE_ENUM16) { + // For unsigned integer types and enum, NaN is represented by the maximum value + return static_cast(std::numeric_limits::max()); + } + + // For other integer types, return 0 as a fallback + return static_cast(0); + } + + return std::numeric_limits::quiet_NaN(); // For floating-point types, return NaN +} + #ifdef USE_SENSOR template void ZigbeeAttribute::connect(sensor::Sensor *sensor) { - sensor->add_on_state_callback([this](float value) { this->set_attr((T) (this->scale_ * value)); }); + sensor->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_(value)); }); +} +template void ZigbeeAttribute::connect(sensor::Sensor *sensor, F &&f) { + sensor->add_on_state_callback([f = std::forward(f), this](float value) { this->set_attr((T) f(value)); }); } #endif #ifdef USE_BINARY_SENSOR diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 700267ef50e..c45f5800459 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -1,8 +1,31 @@ from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE -from esphome.core import CORE +from esphome.const import ( + CONF_ID, + CONF_LAMBDA, + CONF_TYPE, + CONF_VALUE, + DEVICE_CLASS_ATMOSPHERIC_PRESSURE, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_PM25, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_HECTOPASCAL, + UNIT_LITRE_PER_HOUR, + UNIT_LUX, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_PARTS_PER_MILLION, + UNIT_PASCAL, + UNIT_PERCENT, +) +from esphome.core import CORE, Lambda from .const import ( CONF_MAX_EP_NUMBER, @@ -12,70 +35,264 @@ from .const import ( REPORT, ) from .const_esp32 import ( + ALLOWED_UNITS, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, + CONNECT, DEVICE_TYPE, KEY_ZIGBEE_EP, KEY_ZIGBEE_EP_NO_NUM, KEY_ZIGBEE_FIRST_EP_CL, ROLE, + SCALE, ) # endpoint configs: -ep_configs: dict[str, dict[str, Any]] = { - "binary_input": { - DEVICE_TYPE: "SIMPLE_SENSOR", +ANALOG_INPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_INPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + +BINARY_INPUT_EP = { + DEVICE_TYPE: "SIMPLE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_INPUT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x55, + CONF_TYPE: "BOOL", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + }, + { + CONF_ATTRIBUTE_ID: 0x51, + CONF_TYPE: "BOOL", + }, + { + CONF_ATTRIBUTE_ID: 0x6F, + CONF_TYPE: "MAP8", + }, + { + CONF_ATTRIBUTE_ID: 0x1C, + CONF_TYPE: "STRING", + }, + ], + }, + ], +} + + +def _pressure_ep(device_type: bool = False) -> dict[str, Any]: + ep = { + ALLOWED_UNITS: [UNIT_HECTOPASCAL, UNIT_PASCAL], CONF_CLUSTERS: [ { - CONF_ID: "BINARY_INPUT", + CONF_ID: "PRESSURE_MEASUREMENT", ROLE: "SERVER", CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x55, - CONF_TYPE: "BOOL", + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "INT16", CONF_REPORT: cv.enum(REPORT, lower=True)("default"), - CONF_DEVICE: None, + CONNECT: True, + SCALE: { + UNIT_HECTOPASCAL: 1, + UNIT_PASCAL: 0.01, + }, }, + ], + }, + ], + } + if device_type: + ep[DEVICE_TYPE] = ( + "PRESSURE_SENSOR" # Sensor that measures pressure of liquids like water + ) + return ep + + +SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { + DEVICE_CLASS_TEMPERATURE: { + ALLOWED_UNITS: [UNIT_CELSIUS], + DEVICE_TYPE: "TEMPERATURE_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "TEMPERATURE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x51, - CONF_TYPE: "BOOL", - }, - { - CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "MAP8", - }, - { - CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "STRING", + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "INT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + SCALE: 100, + CONNECT: True, }, ], }, ], }, - "analog_input": { + DEVICE_CLASS_HUMIDITY: { + ALLOWED_UNITS: [UNIT_PERCENT], CONF_CLUSTERS: [ { - CONF_ID: "ANALOG_INPUT", + CONF_ID: "REL_HUMIDITY_MEASUREMENT", ROLE: "SERVER", CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x55, + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + SCALE: 100, + CONNECT: True, + }, + ], + }, + ], + }, + DEVICE_CLASS_ATMOSPHERIC_PRESSURE: _pressure_ep(), + DEVICE_CLASS_PRESSURE: _pressure_ep(device_type=True), + DEVICE_CLASS_VOLUME_FLOW_RATE: { + ALLOWED_UNITS: [UNIT_LITRE_PER_HOUR, UNIT_CUBIC_METER_PER_HOUR], + DEVICE_TYPE: "FLOW_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "FLOW_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + SCALE: { + UNIT_LITRE_PER_HOUR: 0.01, + UNIT_CUBIC_METER_PER_HOUR: 10, + }, + }, + ], + }, + ], + }, + DEVICE_CLASS_ILLUMINANCE: { + ALLOWED_UNITS: [UNIT_LUX], + DEVICE_TYPE: "LIGHT_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "ILLUMINANCE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "UINT16", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONF_LAMBDA: cv.lambda_( + Lambda( + "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN + " if (x < 1.0f) return 0;" # too small to measure + " const float v = log10(x)*10000 + 1;" + " return v > 65534.0f ? 0xFFFE : (uint16_t) lroundf(v);" # clamp to 0xFFFE if too large + ) + ), + CONNECT: True, + }, + ], + }, + ], + }, + DEVICE_CLASS_PM25: { + ALLOWED_UNITS: [UNIT_MICROGRAMS_PER_CUBIC_METER], + CONF_CLUSTERS: [ + { + CONF_ID: "PM2_5_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", CONF_REPORT: cv.enum(REPORT, lower=True)("default"), - CONF_DEVICE: None, + CONNECT: True, }, { - CONF_ATTRIBUTE_ID: 0x51, - CONF_TYPE: "BOOL", + CONF_ATTRIBUTE_ID: 0x2, + CONF_TYPE: "SINGLE", + CONF_VALUE: 9999, # overwrite default 1.0 }, + ], + }, + ], + }, + DEVICE_CLASS_CARBON_DIOXIDE: { + ALLOWED_UNITS: [UNIT_PARTS_PER_MILLION], + CONF_CLUSTERS: [ + { + CONF_ID: "CARBON_DIOXIDE_MEASUREMENT", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ { - CONF_ATTRIBUTE_ID: 0x6F, + CONF_ATTRIBUTE_ID: 0x0000, + CONF_TYPE: "SINGLE", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, + SCALE: 0.000001, + }, + {CONF_ATTRIBUTE_ID: 0x0001, CONF_TYPE: "SINGLE", CONF_VALUE: 0.0}, + {CONF_ATTRIBUTE_ID: 0x0002, CONF_TYPE: "SINGLE", CONF_VALUE: 0.1}, + ], + }, + ], + }, +} + +BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { + DEVICE_CLASS_OCCUPANCY: { + DEVICE_TYPE: "OCCUPANCY_SENSOR", + CONF_CLUSTERS: [ + { + CONF_ID: "OCCUPANCY_SENSING", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, }, { - CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "STRING", + CONF_ATTRIBUTE_ID: 0x1, + CONF_TYPE: "ENUM8", + CONF_VALUE: 0, # hardcode PIR for now as ultrasonic or physical contact is unlikely + }, + { + CONF_ATTRIBUTE_ID: 0x2, + CONF_TYPE: "MAP8", + CONF_VALUE: 0b00000001, # hardcode PIR for now as ultrasonic or physical contact is unlikely }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 65ceae8a4bf..c00b13cffd8 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -181,13 +181,8 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi } void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { - ezb_af_ep_config_t config = { - .ep_id = endpoint_id, - .app_profile_id = EZB_AF_HA_PROFILE_ID, - .app_device_id = device_id, - .app_device_version = 0, - }; - ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + ezb_af_ep_desc_t ep_desc = + esphome_zb_zha_default_ep_desc_create(endpoint_id, device_id, this->basic_cluster_data_.power_source); if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } @@ -232,13 +227,13 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { .power_source = this->basic_cluster_data_.power_source, }; cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, this->basic_cluster_data_.manufacturer); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } bool ZigbeeComponent::register_device() { diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 9aae8d4557d..d5e8ebbf5ca 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -17,9 +17,9 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACCURACY_DECIMALS, CONF_AP, - CONF_DEVICE, CONF_DEVICE_CLASS, CONF_ID, + CONF_LAMBDA, CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, @@ -38,6 +38,7 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_CLUSTER, CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, @@ -48,19 +49,29 @@ from .const import ( ZigbeeAttribute, ) from .const_esp32 import ( + ALLOWED_UNITS, ATTR_TYPE, CLUSTER_ID, CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, + CONNECT, DEVICE_ID, DEVICE_TYPE, + KEY_ZIGBEE_ATTRIBUTES, KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs +from .zigbee_ep_esp32 import ( + ANALOG_INPUT_EP, + BINARY_INPUT_EP, + BINARY_SENSOR_EP_CONFIGS, + SENSOR_EP_CONFIGS, + add_ep, + create_ep, +) _LOGGER = logging.getLogger(__name__) @@ -79,8 +90,11 @@ def get_c_type(attr_type: str) -> Any | None: if "STRING" in attr_type: return cg.std_string test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) - if test and test.group(2): + if test: return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + return getattr(cg, "int" + get_c_size(test.group(1), [8, 16, 32, 64])) return None @@ -92,8 +106,11 @@ def get_cv_by_type(attr_type: str) -> Any | None: if "STRING" in attr_type: return cv.string test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) - if test and test.group(2): + if test: return cv.positive_int + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + return cv.int_ raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") @@ -104,6 +121,14 @@ def get_default_by_type(attr_type: str) -> str | bool | int | float: return False if attr_type in ["SINGLE", "DOUBLE"]: return float("nan") + test = re.match(r"^(UINT|ENUM)(\d{1,2})$", attr_type) + if test: + # ZCL "invalid value" sentinel for unsigned ints is the maximum (0xFFFF for UINT16) + return 2 ** (int(test.group(2))) - 1 + test = re.match(r"^INT(\d{1,2})$", attr_type) + if test: + # ZCL "invalid value" sentinel for signed ints is the minimum (0x8000 for INT16) + return -(1 << (int(test.group(1)) - 1)) return 0 @@ -167,59 +192,84 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None ) # or use unidecode attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) - if CONF_DEVICE in attr: # connect device - attr[CONF_DEVICE] = config[CONF_ID] + if attr.get(CONNECT): # connect device if CONF_REPORT in config: attr[CONF_REPORT] = config[CONF_REPORT] attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) - if "zb_attr_ids" not in config: - config["zb_attr_ids"] = [] - config["zb_attr_ids"].append(attr[CONF_ID]) + attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) + attr_list.append(attr) else: attr[CONF_ID] = None validate_attributes(attr) def validate_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["analog_input"]) # get application type from device class and meas unit # if none get BACNET unit from meas unit dev_class = config.get(CONF_DEVICE_CLASS) unit = config.get(CONF_UNIT_OF_MEASUREMENT) - apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) - bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) - accuracy = config.get(CONF_ACCURACY_DECIMALS) - if apptype is not None: + if config[CONF_CLUSTER] == "device_class": + if dev_class not in SENSOR_EP_CONFIGS: + raise cv.Invalid( + "'cluster: device_class' requires a supported 'device_class'. " + f"Supported: {', '.join(SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise." + ) + ep = copy.deepcopy(SENSOR_EP_CONFIGS[dev_class]) + if unit not in ep[ALLOWED_UNITS]: + raise cv.Invalid( + f"Device class '{dev_class}' requires one of units {', '.join(ep[ALLOWED_UNITS])}." + ) + for attr in ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES]: + if isinstance(attr.get(SCALE), dict): + attr[SCALE] = attr[SCALE][unit] + if isinstance(attr.get(CONF_LAMBDA), dict): + attr[CONF_LAMBDA] = attr[CONF_LAMBDA][unit] + else: + ep = copy.deepcopy(ANALOG_INPUT_EP) + apptype = ANALOG_INPUT_APPTYPE.get((dev_class, unit)) + bacunit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) + accuracy = config.get(CONF_ACCURACY_DECIMALS) + if apptype is not None: + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x100, + CONF_VALUE: (apptype << 16) | 0xFFFF, + CONF_TYPE: "UINT32", + }, + ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { - CONF_ATTRIBUTE_ID: 0x100, - CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "UINT32", - }, - ) - ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( - { - CONF_ATTRIBUTE_ID: 0x75, - CONF_VALUE: bacunit, - CONF_TYPE: "ENUM16", - }, - ) - if accuracy is not None: - # Analog Input Resolution (0x006A): smallest reportable change - ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( - { - CONF_ATTRIBUTE_ID: 0x6A, - CONF_VALUE: 10**-accuracy, - CONF_TYPE: "SINGLE", + CONF_ATTRIBUTE_ID: 0x75, + CONF_VALUE: bacunit, + CONF_TYPE: "ENUM16", }, ) + if accuracy is not None: + # Analog Input Resolution (0x006A): smallest reportable change + ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( + { + CONF_ATTRIBUTE_ID: 0x6A, + CONF_VALUE: 10**-accuracy, + CONF_TYPE: "SINGLE", + }, + ) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: - ep = copy.deepcopy(ep_configs["binary_input"]) + dev_class = config.get(CONF_DEVICE_CLASS) + if config[CONF_CLUSTER] == "device_class": + if dev_class in BINARY_SENSOR_EP_CONFIGS: + ep = copy.deepcopy(BINARY_SENSOR_EP_CONFIGS[dev_class]) + else: + raise cv.Invalid( + "'cluster: device_class' requires a supported 'device_class'. " + f"Supported: {', '.join(BINARY_SENSOR_EP_CONFIGS)}. Use 'cluster: generic' otherwise." + ) + else: + ep = copy.deepcopy(BINARY_INPUT_EP) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -274,15 +324,11 @@ async def attributes_to_code( ) await cg.register_component(attr_var, attr) - cg.add(attr_var.add_attr(attr[CONF_VALUE])) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + cg.add(attr_var.add_attr(template_arg, attr[CONF_VALUE])) if CONF_REPORT in attr: cg.add(attr_var.set_report(attr[CONF_REPORT])) - if CONF_DEVICE in attr: - device = await cg.get_variable(attr[CONF_DEVICE]) - template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) - cg.add(attr_var.connect(template_arg, device)) - async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( @@ -332,3 +378,19 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": ) await attributes_to_code(var, ep_num, cl) return var + + +async def add_component(entity: cg.MockObj, config: ConfigType) -> None: + attrs = config.get(KEY_ZIGBEE_ATTRIBUTES, []) + for attr in attrs: + zb_attr = await cg.get_variable(attr[CONF_ID]) + template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE])) + if attr.get(CONF_LAMBDA) is not None: + lambda_ = await cg.process_lambda( + attr[CONF_LAMBDA], + [(cg.float_, "x")], + return_type=get_c_type(attr[CONF_TYPE]), + ) + cg.add(zb_attr.connect(template_arg, entity, lambda_)) + else: + cg.add(zb_attr.connect(template_arg, entity)) diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 0793669955a..53f2c31e8d4 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -24,6 +24,36 @@ ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } +ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source) { + ezb_af_ep_desc_t ep_desc; + switch (device_id) { + case EZB_ZHA_LIGHT_SENSOR_DEVICE_ID: { + ezb_zha_light_sensor_config_t config = EZB_ZHA_LIGHT_SENSOR_CONFIG(); + config.basic_cfg.power_source = power_source; + ep_desc = ezb_zha_create_light_sensor(ep_id, &config); + break; + } + case EZB_ZHA_TEMPERATURE_SENSOR_DEVICE_ID: { + ezb_zha_temperature_sensor_config_t config = EZB_ZHA_TEMPERATURE_SENSOR_CONFIG(); + config.basic_cfg.power_source = power_source; + ep_desc = ezb_zha_create_temperature_sensor(ep_id, &config); + break; + } + default: { + // For other device IDs no macro exists, so we create an empty endpoint descriptor + ezb_af_ep_config_t config = { + .ep_id = ep_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ep_desc = ezb_af_create_endpoint_desc(&config); + break; + } + } + return ep_desc; +} + ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { switch (cluster_id) { case EZB_ZCL_CLUSTER_ID_BASIC: @@ -36,6 +66,22 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT: + return ezb_zcl_illuminance_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT: + return ezb_zcl_temperature_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT: + return ezb_zcl_pressure_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT: + return ezb_zcl_flow_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT: + return ezb_zcl_rel_humidity_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING: + return ezb_zcl_occupancy_sensing_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT: + return ezb_zcl_carbon_dioxide_measurement_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT: + return ezb_zcl_pm2_5_measurement_create_cluster_desc(NULL, role_mask); default: { ezb_zcl_custom_cluster_config_t config = {0}; config.cluster_id = cluster_id; @@ -57,6 +103,22 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT: + return ezb_zcl_illuminance_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT: + return ezb_zcl_temperature_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT: + return ezb_zcl_pressure_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT: + return ezb_zcl_flow_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT: + return ezb_zcl_rel_humidity_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING: + return ezb_zcl_occupancy_sensing_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT: + return ezb_zcl_carbon_dioxide_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT: + return ezb_zcl_pm2_5_measurement_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: return EZB_ERR_NOT_FOUND; } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 6898068b442..305ab2224c7 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -10,6 +10,7 @@ extern "C" { #include "esp_zigbee.h" +ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source); ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f47cf6bd40e..588305da300 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -314,14 +314,6 @@ async def _ctx_to_code(config: ConfigType) -> None: cg.add(cg.RawExpression("ZB_AF_REGISTER_DEVICE_CTX(&zb_device_ctx)")) -async def zephyr_setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: - CORE.add_job(_add_binary_sensor, entity, config) - - -async def zephyr_setup_sensor(entity: cg.MockObj, config: ConfigType) -> None: - CORE.add_job(_add_sensor, entity, config) - - async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None: CORE.add_job(_add_switch, entity, config) @@ -404,7 +396,7 @@ async def _add_zigbee_ep( cg.add(var.set_parent(hub)) -async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: await _add_zigbee_ep( entity, config, @@ -416,7 +408,7 @@ async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: ) -async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_sensor(entity: cg.MockObj, config: ConfigType) -> None: # Get BACnet engineering unit from unit_of_measurement unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index ac25fb8faf7..706da1f10c1 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -3,13 +3,19 @@ packages: binary_sensor: - platform: template - name: "Garage Door Open 10" + name: "Occupancy Sensor" report: "default" - use_device_type: false + device_class: occupancy + cluster: device_class + endpoint: 3 - platform: template name: "Garage Door Open 12" report: "force" endpoint: 1 + - platform: template + name: "Garage Door Open 13" + endpoint: 2 + use_device_type: false sensor: - platform: template @@ -19,6 +25,25 @@ sensor: unit_of_measurement: "°C" endpoint: 1 use_device_type: true + cluster: device_class + - platform: template + name: "Light Sensor" + lambda: return 100.0; + device_class: illuminance + unit_of_measurement: "lx" + endpoint: 2 + use_device_type: true + cluster: device_class + - platform: template + name: "pressure_sensor" + unit_of_measurement: Pa + device_class: pressure + cluster: device_class + - platform: template + name: "flow_sensor" + unit_of_measurement: L/h + device_class: volume_flow_rate + cluster: device_class zigbee: model: zigbee_test diff --git a/tests/components/zigbee/validate.esp32-c6-idf.yaml b/tests/components/zigbee/validate.esp32-c6-idf.yaml new file mode 100644 index 00000000000..873b2286826 --- /dev/null +++ b/tests/components/zigbee/validate.esp32-c6-idf.yaml @@ -0,0 +1,36 @@ +packages: + zigbee: !include common_esp32.yaml + +sensor: + - platform: template + name: "humidity_sensor" + unit_of_measurement: "%" + device_class: humidity + cluster: device_class + endpoint: 2 + use_device_type: false + - platform: template + name: "pm25_sensor" + unit_of_measurement: 'µg/m³' + device_class: pm25 + cluster: device_class + - platform: template + name: "co2_sensor" + unit_of_measurement: ppm + device_class: carbon_dioxide + cluster: device_class + - platform: template + name: "pressure_sensor2" + unit_of_measurement: hPa + device_class: pressure + cluster: device_class + - platform: template + name: "flow_sensor2" + unit_of_measurement: m³/h + device_class: volume_flow_rate + cluster: device_class + - platform: template + name: "atmospheric_pressure_sensor" + unit_of_measurement: hPa + device_class: atmospheric_pressure + cluster: device_class From 4a00cebd61867a575de211ff499e082a17d51f25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:13:53 +0200 Subject: [PATCH 739/759] [kuntze] Use the sub_* helpers for optional child entities (#19926) --- esphome/components/kuntze/sensor.py | 36 +++++++---------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 51d23991e28..40e7447d669 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -101,31 +101,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_PH in config: - conf = config[CONF_PH] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ph_sensor(sens)) - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens)) - if CONF_DIS1 in config: - conf = config[CONF_DIS1] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis1_sensor(sens)) - if CONF_DIS2 in config: - conf = config[CONF_DIS2] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis2_sensor(sens)) - if CONF_REDOX in config: - conf = config[CONF_REDOX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_redox_sensor(sens)) - if CONF_EC in config: - conf = config[CONF_EC] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ec_sensor(sens)) - if CONF_OCI in config: - conf = config[CONF_OCI] - sens = await sensor.new_sensor(conf) - cg.add(var.set_oci_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PH, var.set_ph_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_DIS1, var.set_dis1_sensor) + await sensors(CONF_DIS2, var.set_dis2_sensor) + await sensors(CONF_REDOX, var.set_redox_sensor) + await sensors(CONF_EC, var.set_ec_sensor) + await sensors(CONF_OCI, var.set_oci_sensor) From 4a34315fc7c8a958112e9f1cbd34f16b34b85638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:14:11 +0200 Subject: [PATCH 740/759] [ufm01] Use the sub_* helpers for optional child entities (#19927) --- esphome/components/ufm01/binary_sensor.py | 26 +++++++++-------------- esphome/components/ufm01/sensor.py | 17 +++++---------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py index 59583357e4f..81d8db9a52a 100644 --- a/esphome/components/ufm01/binary_sensor.py +++ b/esphome/components/ufm01/binary_sensor.py @@ -34,20 +34,14 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + hub = await cg.get_variable(config[CONF_UFM01_ID]) - if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): - sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) - cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) - - if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): - sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) - cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) - - if empty_tube_config := config.get(CONF_EMPTY_TUBE): - sens = await binary_sensor.new_binary_sensor(empty_tube_config) - cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) - - if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): - sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) - cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_UFC_CHIP_ERROR, hub.set_ufc_chip_error_binary_sensor) + await binary_sensors( + CONF_FLOW_DIRECTION_WRONG, hub.set_flow_direction_wrong_binary_sensor + ) + await binary_sensors(CONF_EMPTY_TUBE, hub.set_empty_tube_binary_sensor) + await binary_sensors( + CONF_FLOW_RATE_OUT_OF_RANGE, hub.set_flow_rate_out_of_range_binary_sensor + ) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py index e3281f0b2da..581cdd4a9ba 100644 --- a/esphome/components/ufm01/sensor.py +++ b/esphome/components/ufm01/sensor.py @@ -49,16 +49,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + hub = await cg.get_variable(config[CONF_UFM01_ID]) - if CONF_ACCUMULATED_FLOW in config: - sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) - cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) - - if CONF_FLOW in config: - sens = await sensor.new_sensor(config[CONF_FLOW]) - cg.add(ufm01_component.set_flow_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(ufm01_component.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCUMULATED_FLOW, hub.set_accumulated_flow_sensor) + await sensors(CONF_FLOW, hub.set_flow_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) From 0fd0f72550c3ee16055919784acd481207d522ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:14:27 +0200 Subject: [PATCH 741/759] [alpha3] Use the sub_* helpers for optional child entities (#19928) --- esphome/components/alpha3/sensor.py | 30 +++++++---------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index 2c1a04ef274..d8d59242fac 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -74,26 +74,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await ble_client.register_ble_node(var, config) - if flow_config := config.get(CONF_FLOW): - sens = await sensor.new_sensor(flow_config) - cg.add(var.set_flow_sensor(sens)) - - if head_config := config.get(CONF_HEAD): - sens = await sensor.new_sensor(head_config) - cg.add(var.set_head_sensor(sens)) - - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - - if speed_config := config.get(CONF_SPEED): - sens = await sensor.new_sensor(speed_config) - cg.add(var.set_speed_sensor(sens)) - - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FLOW, var.set_flow_sensor) + await sensors(CONF_HEAD, var.set_head_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_SPEED, var.set_speed_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) From 4d534dc420309de2d6d1ed8ebb9d02bcb441c3c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:14:40 +0200 Subject: [PATCH 742/759] [uponor_smatrix] Use the sub_* helpers for optional child entities (#19929) --- .../uponor_smatrix/sensor/__init__.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index 52e755f0054..8167506d783 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -67,15 +67,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - if target_temperature_config := config.get(CONF_TARGET_TEMPERATURE): - sens = await sensor.new_sensor(target_temperature_config) - cg.add(var.set_target_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor) From e111f1130cc67a7e22d6e8a65ece2b07126fa309 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:14:51 +0200 Subject: [PATCH 743/759] [max9611] Use the sub_* helpers for optional child entities (#19930) --- esphome/components/max9611/sensor.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index 9332274a955..e6309b21ccb 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -77,19 +77,8 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_current_resistor(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_gain(config[CONF_GAIN])) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_watt_sensor(sens)) - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temp_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_watt_sensor) + await sensors(CONF_TEMPERATURE, var.set_temp_sensor) From 5baad05926a3fe170d10d2d288e77c0c8e68e396 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:54:38 +1000 Subject: [PATCH 744/759] [lvgl] Use contextvars to improve lvgl scheduling (#19242) Co-authored-by: Claude Opus 5.5 --- esphome/components/lvgl/__init__.py | 18 ++- esphome/components/lvgl/automation.py | 19 +-- .../components/lvgl/binary_sensor/__init__.py | 3 +- esphome/components/lvgl/defines.py | 52 ++++--- esphome/components/lvgl/light/__init__.py | 3 +- esphome/components/lvgl/lvcode.py | 33 +++-- esphome/components/lvgl/number/__init__.py | 3 +- esphome/components/lvgl/schemas.py | 6 +- esphome/components/lvgl/sensor/__init__.py | 3 +- esphome/components/lvgl/styles.py | 94 +++++++------ esphome/components/lvgl/switch/__init__.py | 3 +- esphome/components/lvgl/text/__init__.py | 3 +- .../components/lvgl/text_sensor/__init__.py | 3 +- esphome/components/lvgl/widgets/__init__.py | 60 +++----- esphome/components/lvgl/widgets/button.py | 7 +- .../components/lvgl/widgets/buttonmatrix.py | 3 +- esphome/components/lvgl/widgets/canvas.py | 10 +- esphome/components/lvgl/widgets/keyboard.py | 50 +++---- esphome/components/lvgl/widgets/lv_list.py | 126 ++++++----------- esphome/components/lvgl/widgets/meter.py | 4 +- esphome/components/lvgl/widgets/page.py | 10 +- esphome/components/lvgl/widgets/table.py | 4 +- .../config/keyboard_before_textarea_test.yaml | 28 ++++ .../config/keyboard_update_textarea_test.yaml | 34 +++++ .../config/multi_instance_theme_test.yaml | 35 +++++ .../lvgl/test_keyboard_before_textarea.py | 47 +++++++ .../lvgl/test_keyboard_update_textarea.py | 33 +++++ tests/component_tests/lvgl/test_list.py | 8 +- .../lvgl/test_list_on_add_lvgl_action.py | 14 +- .../lvgl/test_multi_instance_theme.py | 44 ++++++ tests/components/lvgl/lvgl-package.yaml | 18 +++ .../test_action_to_code_context_isolation.py | 56 ++++++++ .../lvgl/test_get_part_state_selector.py | 33 +++++ .../lvgl/test_list_trigger_ordering.py | 129 ++++++++++++++++++ .../components/lvgl/test_table_codegen.py | 3 - .../lvgl/test_theme_update_ordering.py | 44 ++++++ 36 files changed, 726 insertions(+), 317 deletions(-) create mode 100644 tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml create mode 100644 tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml create mode 100644 tests/component_tests/lvgl/config/multi_instance_theme_test.yaml create mode 100644 tests/component_tests/lvgl/test_keyboard_before_textarea.py create mode 100644 tests/component_tests/lvgl/test_keyboard_update_textarea.py create mode 100644 tests/component_tests/lvgl/test_multi_instance_theme.py create mode 100644 tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py create mode 100644 tests/unit_tests/components/lvgl/test_get_part_state_selector.py create mode 100644 tests/unit_tests/components/lvgl/test_list_trigger_ordering.py create mode 100644 tests/unit_tests/components/lvgl/test_theme_update_ordering.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 3822664c948..ca9a317fec0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -61,7 +61,6 @@ from .defines import ( get_focused_widgets, get_lv_images_used, get_refreshed_widgets, - set_widgets_completed, ) from .encoders import ( ENCODERS_CONFIG, @@ -108,6 +107,7 @@ from .widgets import ( get_screen_active, set_obj_properties, ) +from .widgets.keyboard import attach_textareas # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -396,6 +396,11 @@ async def to_code(configs): default_group = get_default_group(config_0) df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE] + # Create theme lambdas before any widgets. + async with LvContext(): + for config in configs: + await theme_to_code(config) + for config in configs: frac = config[CONF_BUFFER_SIZE] if frac >= 0.75: @@ -448,7 +453,6 @@ async def to_code(configs): await touchscreens_to_code(lv_component, config) await encoders_to_code(lv_component, config, default_group) await keypads_to_code(lv_component, config, default_group) - await theme_to_code(config) await gradients_to_code(config) await styles_to_code(config) await set_obj_properties(lv_scr_act, config) @@ -459,20 +463,14 @@ async def to_code(configs): await msgboxes_to_code(lv_component, config) await animations_to_code(config.get(CONF_ANIMATIONS, [])) - # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. - set_widgets_completed(True) async with LvContext(): - # Local import: lv_list imports meter, which imports obj_spec/set_obj_properties - # from this module's own namespace - a top-level import here would be circular. + # Local import to avoid circularity from .widgets.lv_list import finish_list_triggers - # Must run before generate_triggers(): that's what actually processes other - # widgets' on_click etc. automations, which can include lvgl.list.add/remove/ - # clear actions that fire a list's on_add/on_remove triggers - those need to - # already exist by then, not still be pending. await finish_list_triggers() await generate_triggers() await generate_align_tos(configs[0]) + await attach_textareas() for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index c23a36c3892..d797688a7e8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -78,7 +78,6 @@ from .widgets import ( get_screen_active, get_widgets, set_obj_properties, - wait_for_widgets, ) # Widgets that are used in a focused/refreshed action are tracked in @@ -127,15 +126,7 @@ async def action_to_code( action_id, template_arg, args, - config=None, ): - # Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched. - if config: - for lamb in config.values(): - if isinstance(lamb, Lambda): - for id_ in lamb.requires_ids: - await get_variable(id_) - await wait_for_widgets() async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) @@ -145,7 +136,7 @@ async def action_to_code( async def update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): await set_obj_properties(widget, config) - await widget.type.to_code(widget, config) + await widget.type.update_to_code(widget, config) if ( widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config @@ -153,9 +144,7 @@ async def update_to_code(config, action_id, template_arg, args): lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) @automation.register_condition( @@ -507,9 +496,7 @@ async def obj_update_to_code(config, action_id, template_arg, args): await set_obj_properties(widget, config) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) def validate_refresh_config(config): diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index aa68e764211..fc7317f8b97 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -9,7 +9,7 @@ from esphome.const import CONF_STATE from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t -from ..widgets import Widget, get_widgets, wait_for_widgets +from ..widgets import Widget, get_widgets STATE_PRESSED = "PRESSED" STATE_CHECKED = "CHECKED" @@ -33,7 +33,6 @@ async def to_code(config): widget = widget[0] assert isinstance(widget, Widget) state = await BS_STATE.process(config[CONF_STATE]) - await wait_for_widgets() is_pressed = str(state) == str(LV_STATE.PRESSED) test_expr = widget.is_pressed() if is_pressed else widget.is_checked() async with LambdaContext(EVENT_ARG) as test_ctx: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index fbb621800b5..c019dff96e0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -20,21 +20,22 @@ DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" KEY_FOCUSED_WIDGETS = "focused_widgets" +KEY_LIST_TRIGGERS = "list_triggers" KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" KEY_NAMED_STYLES = "named_styles" +KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" -KEY_THEME_WIDGET_MAP = "theme_widget_map" +KEY_THEME_STYLES = "theme_styles" KEY_UPDATED_WIDGETS = "updated_widgets" -KEY_WIDGET_MAP = "widget_map" -KEY_WIDGETS_COMPLETED = "widgets_completed" -KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGET_THEME_STYLES = "widget_theme_styles" KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" # Colours for the debug outline, in (red, green, blue) order. They are picked to stay @@ -137,8 +138,14 @@ def get_updated_widgets() -> dict: return _get_data(KEY_UPDATED_WIDGETS, {}) -def get_theme_widget_map() -> dict[str, Any]: - return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_styles() -> dict[str, MockObj]: + """Get a map of already created theme style names to their corresponding style IDs.""" + return _get_data(KEY_THEME_STYLES, {}) + + +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: + """Get the map of widget type names to the list of (style variable, part/state name)""" + return _get_data(KEY_WIDGET_THEME_STYLES, {}) def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: @@ -157,20 +164,6 @@ def get_widget_map() -> dict[str, Any]: return _get_data(KEY_WIDGET_MAP, {}) -def get_widgets_completed() -> bool: - # ``[value]`` rather than the bare value so that we can mutate the - # entry in place; ``CORE.data`` is reset for us between runs. - return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] - - -def set_widgets_completed(value: bool) -> None: - _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value - - -def is_widget_completed(name: ID) -> bool: - return name in get_widget_map() - - def get_focused_widgets() -> set: return _get_data(KEY_FOCUSED_WIDGETS, set()) @@ -179,6 +172,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_list_triggers() -> dict: + return _get_data(KEY_LIST_TRIGGERS, {}) + + def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -869,7 +866,7 @@ LV_SCALE_MODE = LvConstant( DEFAULT_ESPHOME_FONT = "esphome_lv_default_font" -def join_enums(enums, prefix=""): +def join_enums(enums: tuple[str], prefix: str = "") -> MockObj: enums = list(enums) enums.sort() # If a prefix is provided, prepend each constant with the prefix, and assume that all the constants are within the @@ -879,6 +876,19 @@ def join_enums(enums, prefix=""): return literal("|".join(f"(int){e.upper()}" for e in enums)) +def get_part_state_selector(part: str, state: str) -> MockObj: + """Combine a part and state into a single selector value, e.g. LV_PART_KNOB | LV_STATE_PRESSED.""" + state = "LV_STATE_" + state.removeprefix("LV_STATE_").upper() + part = "LV_PART_" + part.removeprefix("LV_PART_").upper() + if state == "LV_STATE_DEFAULT": + return literal(part) + if part == "LV_PART_MAIN": + return literal(state) + return MockObj( + StaticCastExpression("lv_style_selector_t", literal(state)) + ) | MockObj(StaticCastExpression("lv_style_selector_t", literal(part))) + + # fmt: off LV_COLOR_FORMATS = ( "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", diff --git a/esphome/components/lvgl/light/__init__.py b/esphome/components/lvgl/light/__init__.py index dcdf67a5207..0a8720f5eee 100644 --- a/esphome/components/lvgl/light/__init__.py +++ b/esphome/components/lvgl/light/__init__.py @@ -7,7 +7,7 @@ from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID from ..defines import CONF_WIDGET from ..lvcode import LvContext from ..types import LvType, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets lv_led_t = LvType("lv_led_t") LVLight = lvgl_ns.class_("LVLight", LightOutput) @@ -26,6 +26,5 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LvContext() as ctx: ctx.add(var.set_obj(widget.obj)) diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 850b63a26f4..d6d1aaa4144 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -1,5 +1,5 @@ import abc -from typing import TYPE_CHECKING +import contextvars from esphome import codegen as cg from esphome.config import Config @@ -65,15 +65,33 @@ class IndentedStatement(Statement): return result -class CodeContext(abc.ABC): +class _CodeContextMeta(abc.ABCMeta): + """ + Backs `CodeContext.code_context` with a contextvar instead of a plain class + attribute, so the FakeEventLoop's per-task context isolation (see coroutine.py) + keeps interleaved to_code() jobs from clobbering each other's current context. + """ + + _context_var: contextvars.ContextVar["CodeContext | None"] = contextvars.ContextVar( + "code_context", default=None + ) + + @property + def code_context(cls) -> "CodeContext | None": + return _CodeContextMeta._context_var.get() + + @code_context.setter + def code_context(cls, value: "CodeContext | None") -> None: + _CodeContextMeta._context_var.set(value) + + +class CodeContext(abc.ABC, metaclass=_CodeContextMeta): """ A class providing a context for code generation. Generated code will be added to the current context. A new context will stack on the current context, and restore it when done. Used with the `with` statement. """ - code_context = None - @abc.abstractmethod def add(self, expression: Expression | Statement): pass @@ -115,6 +133,9 @@ class CodeContext(abc.ABC): def indented_statement(self, stmt): return IndentedStatement(stmt, self.indent_level) + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + return [] + class MainContext(CodeContext): """ @@ -212,10 +233,6 @@ def get_lambda_context_args() -> list[tuple[SafeExpType, str]]: """ if CodeContext.code_context is None: return [] - if TYPE_CHECKING: - # CodeContext base class doesn't define get_automation_parameters(), - # but LambdaContext and LvContext (the concrete implementations) do. - assert isinstance(CodeContext.code_context, LambdaContext) return CodeContext.code_context.get_automation_parameters() diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index be51963ba12..0d8d99ab218 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -22,7 +22,7 @@ from ..lvcode import ( ) from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA from ..types import LvNumber, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component) @@ -47,7 +47,6 @@ async def to_code(config): trigger = CONF_ON_RELEASE widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext([], return_type=cg.float_) as value: value.add(ReturnStatement(widget.get_value())) async with LambdaContext([(cg.float_, "v")]) as control: diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a546d9f1834..cf766b96c86 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -670,11 +670,7 @@ def theme_update_schema(value: dict) -> dict: for w_name, style in validated.items(): for part, states in collect_parts(style).items(): for state, props in states.items(): - # collect_parts() unconditionally seeds a main/default entry - # even when nothing was set for it (e.g. `{pressed: {...}}` - # alone) -- skip combos with no properties so a request for - # one state doesn't also create an unused, empty main/default - # style that gets attached to every widget of this type. + # Skip states with no properties if not props: continue df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = ( diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index e69ea9771a0..c5ef98b05e5 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -5,7 +5,7 @@ from ..defines import CONF_TRIGGER, CONF_WIDGET from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA from ..types import LvNumber -from ..widgets import Widget, get_widgets, wait_for_widgets +from ..widgets import Widget, get_widgets CONFIG_SCHEMA = sensor_schema(Sensor).extend( { @@ -20,7 +20,6 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) - await wait_for_widgets() async with LambdaContext(EVENT_ARG) as lamb: lv_add(sensor.publish_state(widget.get_value())) async with LvContext(): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index ad420283279..1fef37e2ee8 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -1,17 +1,22 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_DEFAULT, CONF_ID from esphome.core import ID +from esphome.cpp_generator import MockObj from .defines import ( CONF_STYLE_DEFINITIONS, CONF_THEME, + PARTS, + STATES, LValidator, add_lv_use, + get_part_state_selector, get_styles_used, + get_theme_styles, get_theme_update_requests, - get_theme_widget_map, + get_widget_theme_style_data, literal, ) from .lvcode import LambdaContext, lv @@ -23,7 +28,7 @@ from .schemas import ( theme_update_schema, ) from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts, wait_for_widgets +from .widgets import collect_parts def has_style_props(config) -> bool: @@ -89,7 +94,6 @@ async def styles_to_code(config): synchronous=True, ) async def style_update_to_code(config, action_id, template_arg, args): - await wait_for_widgets() style = await cg.get_variable(config[CONF_ID]) async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) @@ -101,41 +105,64 @@ async def style_update_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) +def _get_theme_style_name(w_name: str, part: str, state: str) -> str: + return f"_lv_theme_style_{w_name}_{part}_{state}" + + +def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: + """Return a list of (style variable, part/state name) for all theme styles used by the given widget type.""" + widget_styles = get_widget_theme_style_data() + if w_name in widget_styles: + return widget_styles[w_name] + theme_styles = get_theme_styles() + style_list = [] + for part in PARTS: + for state in STATES + (CONF_DEFAULT,): + style_name = _get_theme_style_name(w_name, part, state) + if style_name in theme_styles: + style_list.append( + (theme_styles[style_name], get_part_state_selector(part, state)) + ) + widget_styles[w_name] = style_list + return style_list + + async def theme_to_code(config): + """ + Convert theme to C++ code. May be called multiple times for different LVGL instances. + A style is created for each (widget type, part, state) combo declared in the `theme:` section of the config, + or requested by a `theme.update` action. + If a style is requested but not declared, it is created as an empty placeholder. + :param config: + :return: + """ theme = config.get(CONF_THEME) or {} requests = get_theme_update_requests() - # Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather - # than a set -- a set of strings/tuples iterates in an order that depends on - # per-process hash randomization, which would otherwise churn the order hidden - # style variables are declared in main.cpp between builds of the same config. widget_names = [ w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests ] if not widget_names: return add_lv_use(CONF_THEME) - theme_map = get_theme_widget_map() + style_map = get_theme_styles() for w_name in widget_names: declared_parts = collect_parts(theme[w_name]) if w_name in theme else {} parts = {part: dict(states) for part, states in declared_parts.items()} for part, state in requests.get(w_name, {}): parts.setdefault(part, {}).setdefault(state, {}) - widget_styles = theme_map.setdefault(w_name, {}) for part, states in parts.items(): - part_styles = widget_styles.setdefault(part, {}) declared_states = declared_parts.get(part, {}) for state, props in states.items(): - if state not in part_styles: - part_styles[state] = await create_style( - "_lv_theme_style_" + w_name + "_" + part + "_" + state, props - ) + style_name = _get_theme_style_name(w_name, part, state) + if style_name not in style_map: + style_map[style_name] = await create_style(style_name, props) elif state in declared_states: # A `theme.update` request for this combo (possibly from # another LVGL instance) already created the style as an # empty placeholder before this instance's real `theme:` # declaration was reached -- apply the real values now # instead of silently leaving it empty. - await style_set(part_styles[state], props) + await style_set(style_map[style_name], props) @automation.register_action( @@ -144,40 +171,25 @@ async def theme_to_code(config): theme_update_schema, synchronous=True, ) -async def theme_update_to_code(config, action_id, template_arg, args): - await wait_for_widgets() - theme_map = get_theme_widget_map() - # Invariant this relies on: theme_update_schema() records every (widget - # type, part, state) combo this action targets as a request during config - # validation (which completes for the whole config tree before any - # to_code runs), and theme_to_code() -- which runs for every LVGL - # instance before any action's own to_code -- materialises a style for - # each recorded request. If that handshake is ever broken by a future - # change, fail with a diagnosable message rather than a bare KeyError. - to_update = [] +async def theme_update_to_code(config, action_id, template_arg, args) -> MockObj: + # The theme_update_schema records the requested (widget type, part, state) combos in a global dict so that + # theme_to_code() can create the corresponding styles variables. Here we await get_variable(), which will + # context switch if required so theme_to_code() can run and create the style variable. + to_update: list[tuple] = [] for w_name, style in config.items(): for part, states in collect_parts(style).items(): for state, props in states.items(): - # collect_parts() unconditionally seeds an (empty) main/default - # entry even when this action didn't target it -- skip it, both - # because there's nothing to update and because - # theme_update_schema no longer pre-creates a placeholder style - # for combos with no properties. + # Skip states with no properties to set. if not props: continue - style_var = theme_map.get(w_name, {}).get(part, {}).get(state) - if style_var is None: - raise cv.Invalid( - f"No theme style exists for '{w_name}' {part}/{state}. " - "This is an internal error -- please report it." - ) + style_var = await cg.get_variable( + ID(_get_theme_style_name(w_name, part, state)) + ) to_update.append((style_var, props)) async with LambdaContext(parameters=args, where=action_id) as context: for style_var, props in to_update: await style_set(style_var, props) - # Refresh and redraw every widget using this style -- otherwise the - # updated properties would sit unused until something else happens - # to invalidate the affected widgets. + # Trigger a redraw for affected widgets. lv.obj_report_style_change(style_var) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index 509e4f42ad7..907665eca61 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -15,7 +15,7 @@ from ..lvcode import ( lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLSwitch = lvgl_ns.class_("LVGLSwitch", Switch, Component) CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend( @@ -28,7 +28,6 @@ CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend( async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() switch_id = MockObj(config[CONF_ID], "->") v = literal("v") async with LambdaContext([(cg.bool_, "v")]) as control: diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index 61db5444e86..3d5e0305b98 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -14,7 +14,7 @@ from ..lvcode import ( lvgl_static, ) from ..types import LV_EVENT, LvText, lvgl_ns -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets LVGLText = lvgl_ns.class_("LVGLText", text.Text) @@ -29,7 +29,6 @@ async def to_code(config): textvar = await new_text(config) widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor/__init__.py index c3306ad57a0..f0575fb1013 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor/__init__.py @@ -8,7 +8,7 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LvText -from ..widgets import get_widgets, wait_for_widgets +from ..widgets import get_widgets CONFIG_SCHEMA = text_sensor_schema(TextSensor).extend( { @@ -21,7 +21,6 @@ async def to_code(config): sensor = await new_text_sensor(config) widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] - await wait_for_widgets() async with LambdaContext(EVENT_ARG) as pressed_ctx: pressed_ctx.add(sensor.publish_state(widget.get_value())) async with LvContext() as ctx: diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index da513e05a85..d69e90056fd 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action -from esphome.config_validation import Invalid, Schema +from esphome.config_validation import Schema from esphome.const import ( CONF_DEFAULT, CONF_GROUP, @@ -44,10 +44,9 @@ from ..defines import ( LValidator, add_lv_use, get_options, + get_part_state_selector, get_styles_used, - get_theme_widget_map, get_widget_map, - get_widgets_completed, join_enums, literal, next_debug_outline_color, @@ -199,13 +198,21 @@ class WidgetType: await self.to_code(w, config) return w - async def to_code(self, w: "Widget", config: dict): + async def to_code(self, w: "Widget", config: dict) -> None: """ - Update a widget, also called when creating + Generate code for widget properties and actions. :param config: :return: """ + async def update_to_code(self, w: "Widget", config: dict) -> None: + """ + Update a widget. Defaults to calling to_code, but can be overridden + :param w: The widget to update + :param config: The configuration for the update + """ + await self.to_code(w, config) + async def obj_creator(self, parent: MockObj, config: dict): """ Create an instance of the widget type @@ -261,17 +268,10 @@ class WidgetType: def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" - for part, states in get_theme_widget_map().get(w.type.name, {}).items(): - part = "LV_PART_" + part.upper() - for state, style in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) - w.add_style(style, lv_state) + from ..styles import get_widget_theme_styles + + for style, lv_state in get_widget_theme_styles(w.type.name): + w.add_style(style, lv_state) def apply_debug_outline(w: "Widget") -> None: @@ -342,8 +342,6 @@ class Widget: return lv_obj.remove_flag(self.obj, literal(flag)) def add_style(self, style_id, state=LV_STATE.DEFAULT): - if "|" in state: - state = f"(lv_state_t)({state})" lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( @@ -476,10 +474,6 @@ def get_widget_generator(wid): while True: if obj := widget_map.get(wid): return obj - if get_widgets_completed(): - raise Invalid( - f"Widget {wid} not found, yet all widgets should be defined by now" - ) yield @@ -489,19 +483,6 @@ async def get_widget_(wid): return await FakeAwaitable(get_widget_generator(wid)) -def widgets_wait_generator(): - while True: - if get_widgets_completed(): - return - yield - - -async def wait_for_widgets(): - if get_widgets_completed(): - return - await FakeAwaitable(widgets_wait_generator()) - - async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]: if not config: return [] @@ -634,15 +615,8 @@ async def set_obj_properties(w: Widget, config): _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): - part = "LV_PART_" + part.upper() for state, props in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) + lv_state = get_part_state_selector(part, state) for style_id in props.get(CONF_STYLES, ()): w.add_style(style_id, lv_state) for prop, value in { diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index 0ad512cd8bb..306f6925018 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -15,9 +15,7 @@ lv_button_t = LvBoolean("lv_button_t") class ButtonType(WidgetType): def __init__(self): - super().__init__( - CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn" - ) + super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA) def validate(self, value): if CONF_TEXT in value: @@ -26,9 +24,6 @@ class ButtonType(WidgetType): add_lv_use("label") return value - def get_uses(self): - return ("btn",) - async def on_create(self, var: MockObj, config: dict): if CONF_TEXT in config: lv.label_create(var) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index 02dc9ed4ba8..854c5e3127d 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -188,7 +188,6 @@ class ButtonMatrixType(WidgetType): (CONF_MAIN, CONF_ITEMS), BUTTONMATRIX_SCHEMA, {}, - lv_name="buttonmatrix", ) async def to_code(self, w: Widget, config): @@ -272,5 +271,5 @@ async def button_update_to_code(config, action_id, template_arg, args): ) return await action_to_code( - widgets, do_button_update, action_id, template_arg, args, config + widgets, do_button_update, action_id, template_arg, args ) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4427a3b00eb..2137acb3f6a 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -141,7 +141,7 @@ async def canvas_fill(config, action_id, template_arg, args): async def do_fill(w: Widget): lv.canvas_fill_bg(w.obj, color, opa) - return await action_to_code(widget, do_fill, action_id, template_arg, args, config) + return await action_to_code(widget, do_fill, action_id, template_arg, args) @automation.register_action( @@ -176,9 +176,7 @@ async def canvas_set_pixel(config, action_id, template_arg, args): x, y = point lv.canvas_set_px(w.obj, x, y, color, opa) - return await action_to_code( - widget, do_set_pixels, action_id, template_arg, args, config - ) + return await action_to_code(widget, do_set_pixels, action_id, template_arg, args) DRAW_SCHEMA = { @@ -214,9 +212,7 @@ async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg await do_draw(addr(layer), x, y, dsc) lv.canvas_finish_layer(w.obj, addr(layer)) - return await action_to_code( - widget, action_func, action_id, template_arg, args, config - ) + return await action_to_code(widget, action_func, action_id, template_arg, args) RECT_PROPS = { diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 65516513a6c..0356e66dac7 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -1,17 +1,9 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ITEMS, CONF_MODE -from esphome.core import CORE from esphome.cpp_types import std_string -from .. import LvContext -from ..defines import ( - CONF_MAIN, - KEYBOARD_MODES, - add_lv_use, - is_widget_completed, - literal, -) +from ..defines import CONF_MAIN, KEYBOARD_MODES, get_widget_map, literal from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX @@ -50,30 +42,32 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL + return ( + CONF_KEYBOARD, + CONF_TEXTAREA, + CONF_BUTTONMATRIX, + CONF_LABEL, + "KEY_LISTENER", + ) async def to_code(self, w: Widget, config: dict): - add_lv_use("KEY_LISTENER") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) - if textarea := config.get(CONF_TEXTAREA): - if not is_widget_completed(textarea): - # Can only happen for an initial config, where the keyboard is configured before the - # textarea, so it's ok to always emit into the global context - async def add_textarea(): - async with LvContext(): - await w.set_property( - CONF_TEXTAREA, - (await get_widgets(config, CONF_TEXTAREA))[0].obj, - ) - CORE.add_job(add_textarea) - else: - # Handles updates in automations, and properly ordered initial config. Code is generated - # into the enclosing context (main or lambda) - await w.set_property( - CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj - ) + async def update_to_code(self, w: "Widget", config: dict) -> None: + await self.to_code(w, config) + if config.get(CONF_TEXTAREA): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) + + +async def attach_textareas(): + for w in get_widget_map().values(): + if w.type == keyboard_spec and w.config.get(CONF_TEXTAREA): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(w.config, CONF_TEXTAREA))[0].obj + ) keyboard_spec = KeyboardType() diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 5d94c2565a2..f41cb8e4e96 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -1,4 +1,3 @@ -from collections.abc import Generator from dataclasses import dataclass, field from typing import Any @@ -15,8 +14,6 @@ from esphome.const import ( CONF_TEXT, CONF_TRIGGER_ID, ) -from esphome.core import CORE -from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -31,6 +28,7 @@ from ..defines import ( SWIPE_TRIGGERS, TYPE_FLEX, add_lv_use, + get_list_triggers, literal, ) from ..lv_validation import lv_int, lv_text, padding @@ -65,6 +63,7 @@ from . import ( ) from .buttonmatrix import CONF_BUTTONMATRIX from .canvas import CONF_CANVAS +from .keyboard import CONF_KEYBOARD from .label import CONF_LABEL from .meter import CONF_METER from .tabview import CONF_TABVIEW @@ -75,8 +74,6 @@ CONF_WIDGET = "widget" CONF_ON_ADD = "on_add" CONF_ON_REMOVE = "on_remove" -DOMAIN = "lvgl_list" - lv_list_t = LvType("lv_list_t") @@ -87,76 +84,49 @@ class ListTriggers: def _get_list_triggers(list_id) -> ListTriggers: - """ - Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the - list's own ID. - """ - triggers_by_list = CORE.data.setdefault(DOMAIN, {}) - return triggers_by_list.setdefault(list_id, ListTriggers()) + """Every on_add/on_remove automation config declared for a list, indexed by the list's own ID.""" + return get_list_triggers().get(list_id, ListTriggers()) -def _get_pending_list_triggers(list_id) -> ListTriggers: - """ - Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation - configs, not yet built. - """ - pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {}) - return pending_by_list.setdefault(list_id, ListTriggers()) - - -def _list_triggers_completed_flag() -> list[bool]: - return CORE.data.setdefault(DOMAIN + "_completed", [False]) - - -def _list_triggers_completed_generator() -> Generator[None, None, None]: - while True: - if _list_triggers_completed_flag()[0]: - return - yield - - -async def _wait_list_triggers_completed() -> None: - """Waits until finish_list_triggers() has built every list's on_add/on_remove automations.""" - if _list_triggers_completed_flag()[0]: - return - await FakeAwaitable(_list_triggers_completed_generator()) +def _declare_list_triggers(list_id) -> ListTriggers: + """Get-or-create a list's trigger record, for ListType.on_create() to populate.""" + return get_list_triggers().setdefault(list_id, ListTriggers()) async def finish_list_triggers() -> None: """ - Builds every list's on_add/on_remove automations, collected by ListType.to_code() - instead of being built there directly. Must run after set_widgets_completed(True). + Builds every list's on_add/on_remove automations, collected by ListType.on_create() + instead of being built there directly. """ - for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items(): - triggers = _get_list_triggers(list_id) - for conf in pending.on_add: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) - triggers.on_add.append(trigger) - for conf in pending.on_remove: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) - await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) - triggers.on_remove.append(trigger) - _list_triggers_completed_flag()[0] = True + # Avoid circular references by creating the trigger Pvariables first + built_triggers = [ + (cg.new_Pvariable(conf[CONF_TRIGGER_ID]), conf) + for triggers in get_list_triggers().values() + for conf in triggers.on_add + triggers.on_remove + ] + for trigger, conf in built_triggers: + await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) -def _fire_index_triggers(triggers: list, index) -> None: - for trigger in triggers: +async def _fire_index_triggers(confs: list, index) -> None: + for conf in confs: + # finish_list_triggers() may not have built this trigger yet if it runs as + # part of a different component's own to_code() job than this one - + # get_variable() blocks until it does, regardless of scheduling order. + trigger = await cg.get_variable(conf[CONF_TRIGGER_ID]) lv_add(trigger.trigger(index)) async def _fire_on_add(list_id, list_obj, entry_obj) -> None: - await _wait_list_triggers_completed() - triggers = _get_list_triggers(list_id).on_add - if not triggers: + confs = _get_list_triggers(list_id).on_add + if not confs: return index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})") - _fire_index_triggers(triggers, index) + await _fire_index_triggers(confs, index) async def _fire_on_remove(list_id, index) -> None: - await _wait_list_triggers_completed() - _fire_index_triggers(_get_list_triggers(list_id).on_remove, index) + await _fire_index_triggers(_get_list_triggers(list_id).on_remove, index) LIST_SCHEMA = cv.Schema( @@ -200,14 +170,15 @@ class ListType(WidgetType): def get_uses(self): return TYPE_FLEX, CONF_LABEL, CONF_BUTTON - async def to_code(self, w: Widget, config: dict): + async def on_create(self, var: MockObj, config: dict) -> None: + # Runs before Widget.create() registers this list on_add = config.get(CONF_ON_ADD, ()) on_remove = config.get(CONF_ON_REMOVE, ()) if not on_add and not on_remove: return - pending = _get_pending_list_triggers(w.config[CONF_ID]) - pending.on_add.extend(on_add) - pending.on_remove.extend(on_remove) + triggers = _declare_list_triggers(config[CONF_ID]) + triggers.on_add.extend(on_add) + triggers.on_remove.extend(on_remove) list_spec = ListType() @@ -228,7 +199,6 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -239,17 +209,19 @@ async def list_add_text_to_code(config, action_id, template_arg, args): lv.obj_move_to_index(entry, await lv_int.process(idx)) await _fire_on_add(config[CONF_ID], w.obj, entry) - return await action_to_code( - widgets, do_add_text, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_add_text, action_id, template_arg, args) +# These types of widgets are not compatible with lvgl.list.add, some since they manage their own children in a way +# that can't be rebuilt on every call, others are just Too Hard. _DYNAMIC_WIDGET_UNSUPPORTED = ( CONF_BUTTONMATRIX, + CONF_CANVAS, + CONF_KEYBOARD, + CONF_LIST, + CONF_METER, CONF_TABVIEW, CONF_TILEVIEW, - CONF_METER, - CONF_CANVAS, ) @@ -372,7 +344,6 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -388,7 +359,7 @@ async def list_add_to_code(config, action_id, template_arg, args): index=index, ) - return await action_to_code(widgets, do_add, action_id, template_arg, args, config) + return await action_to_code(widgets, do_add, action_id, template_arg, args) async def _build_dynamic_widget( @@ -507,7 +478,6 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -528,9 +498,7 @@ async def list_remove_to_code(config, action_id, template_arg, args): # Recursively destroys the whole subtree lv.obj_del(child) - return await action_to_code( - widgets, do_remove, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_remove, action_id, template_arg, args) @automation.register_action( @@ -541,19 +509,15 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_clear(w: Widget): - await _wait_list_triggers_completed() - triggers = _get_list_triggers(config[CONF_ID]).on_remove - if triggers: + confs = _get_list_triggers(config[CONF_ID]).on_remove + if confs: # Fire on_remove for every entry, newest to oldest, before wiping them all out, # so on_remove's semantics ("an entry left the list") hold with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index: - _fire_index_triggers(triggers, index) + await _fire_index_triggers(confs, index) # lv_obj_clean recursively destroys every child's whole subtree lv.obj_clean(w.obj) - return await action_to_code( - widgets, do_clear, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_clear, action_id, template_arg, args) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 166e88f382a..ba322881289 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -612,9 +612,7 @@ async def indicator_update_to_code(config, action_id, template_arg, args): async def set_value(w: Widget): await set_indicator_values(w.parent, w, config) - return await action_to_code( - widget, set_value, action_id, template_arg, args, config - ) + return await action_to_code(widget, set_value, action_id, template_arg, args) async def set_indicator_values(scale: MockObj, indicator: Widget, config): diff --git a/esphome/components/lvgl/widgets/page.py b/esphome/components/lvgl/widgets/page.py index 7e75ab6a2da..9dc9123ac9e 100644 --- a/esphome/components/lvgl/widgets/page.py +++ b/esphome/components/lvgl/widgets/page.py @@ -26,14 +26,7 @@ from ..lvcode import ( ) from ..schemas import LVGL_SCHEMA from ..types import LvglAction, LvglCondition, lv_page_t -from . import ( - Widget, - WidgetType, - add_widgets, - get_widgets, - set_obj_properties, - wait_for_widgets, -) +from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties CONF_ON_LOAD = "on_load" CONF_ON_UNLOAD = "on_unload" @@ -107,7 +100,6 @@ async def page_next_to_code(config, action_id, template_arg, args): ), ) async def page_is_showing_to_code(config, condition_id, template_arg, args): - await wait_for_widgets() page = await cg.get_variable(config[CONF_ID]) async with LambdaContext( [(lv_page_t.operator("ptr"), PAGE_ARG)], return_type=cg.bool_ diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py index f000ea18466..019c6d4ecaf 100644 --- a/esphome/components/lvgl/widgets/table.py +++ b/esphome/components/lvgl/widgets/table.py @@ -274,6 +274,4 @@ async def table_cell_update_to_code( ) await set_cell_ctrl(w, row, column, config) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) diff --git a/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml new file mode 100644 index 00000000000..8f80a942f5d --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml @@ -0,0 +1,28 @@ +esphome: + name: test-keyboard-before-textarea + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: GPIO3 + +lvgl: + widgets: + - keyboard: + id: kb + textarea: ta + - textarea: + id: ta diff --git a/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml new file mode 100644 index 00000000000..1d02a2df587 --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml @@ -0,0 +1,34 @@ +esphome: + name: test-keyboard-update-textarea + on_boot: + - lvgl.keyboard.update: + id: kb + textarea: ta2 + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: GPIO3 + +lvgl: + widgets: + - keyboard: + id: kb + textarea: ta1 + - textarea: + id: ta1 + - textarea: + id: ta2 diff --git a/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml new file mode 100644 index 00000000000..d30f1ff29a1 --- /dev/null +++ b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-multi-instance-theme + +host: + mac_address: "62:23:45:AF:B3:DD" + +display: + - platform: snapshot + id: sdl0 + dimensions: + width: 100 + height: 100 + - platform: snapshot + id: sdl1 + dimensions: + width: 100 + height: 100 + +lvgl: + - id: lvgl_0 + displays: sdl0 + widgets: + - label: + id: label_0 + text: "First" + + - id: lvgl_1 + displays: sdl1 + theme: + label: + bg_color: 0xFF0000 + widgets: + - label: + id: label_1 + text: "Second" diff --git a/tests/component_tests/lvgl/test_keyboard_before_textarea.py b/tests/component_tests/lvgl/test_keyboard_before_textarea.py new file mode 100644 index 00000000000..3da4b1cebe8 --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_before_textarea.py @@ -0,0 +1,47 @@ +"""Regression test: a keyboard: declared before its textarea: sibling must +still get attached to it, and only after both widgets exist. + +attach_textareas() runs as a deferred pass, after every widget (across every +LVGL instance) is created, so it must emit the attach call after the +keyboard's own creation statement, not inline during widget creation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "keyboard_before_textarea_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_keyboard_and_textarea_are_created(main_cpp: str) -> None: + assert "lv_keyboard_create(" in main_cpp + assert "lv_textarea_create(" in main_cpp + + +def test_attach_call_runs_after_keyboard_and_textarea_are_created( + main_cpp: str, +) -> None: + attach_index = main_cpp.find("lv_keyboard_set_textarea(kb->obj, ta);") + assert attach_index != -1, "keyboard was never attached to its textarea" + assert attach_index > main_cpp.find("lv_keyboard_create(") + assert attach_index > main_cpp.find("lv_textarea_create(") diff --git a/tests/component_tests/lvgl/test_keyboard_update_textarea.py b/tests/component_tests/lvgl/test_keyboard_update_textarea.py new file mode 100644 index 00000000000..154d2ee2004 --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_update_textarea.py @@ -0,0 +1,33 @@ +"""Regression test: lvgl.keyboard.update must be able to change which +textarea a keyboard is attached to. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "keyboard_update_textarea_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_keyboard_update_changes_textarea(main_cpp: str) -> None: + assert "lv_keyboard_set_textarea(kb->obj, ta2);" in main_cpp diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 93ee162f1af..368a3a49905 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -286,7 +286,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: - assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp + assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp assert ( "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" in main_cpp @@ -310,7 +310,7 @@ def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None: assert ( "lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n" " lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));\n" + "LV_PART_MAIN);\n" ' lv_label_set_text(dyn_label_1_VAR_, "Nested");' ) in main_cpp @@ -321,7 +321,7 @@ def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None: """ assert ( "lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));" + "LV_PART_MAIN);" ) in main_cpp assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp @@ -343,7 +343,7 @@ def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> Non assert ( "lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n" " lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, " - "(lv_state_t)(LV_PART_MAIN));\n" + "LV_PART_MAIN);\n" ' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");' ) in main_cpp diff --git a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py index 2383adc2518..9ff7b8736e0 100644 --- a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py +++ b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py @@ -1,16 +1,4 @@ -"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock. - -ListType.to_code() used to build the on_add/on_remove automations directly, during -widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only -resolves once *all* widgets - including the list itself - have finished being -created. Building an automation containing an lvgl action from inside that same -widget-creation walk therefore could never complete: codegen deadlocked with -"Circular dependency detected!". Fixed by deferring the actual build_automation() -call to finish_list_triggers(), run after set_widgets_completed(True) - and, -critically, before generate_triggers(), which is what processes other widgets' -on_click etc. automations that might reference this list (e.g. via lvgl.list.add), -and which therefore need the list's own on_add/on_remove triggers to already exist. -""" +"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.""" from __future__ import annotations diff --git a/tests/component_tests/lvgl/test_multi_instance_theme.py b/tests/component_tests/lvgl/test_multi_instance_theme.py new file mode 100644 index 00000000000..82f271b6059 --- /dev/null +++ b/tests/component_tests/lvgl/test_multi_instance_theme.py @@ -0,0 +1,44 @@ +"""Regression test: a widget's theme style must be attached regardless of +which LVGL instance declares theme: and which instance's widgets are built +first. + +theme_to_code() runs once per LVGL instance, interleaved with that instance's +own add_widgets(). A widget built by an earlier instance, before any instance +has declared theming for its type, must still pick up a later instance's +theme: declaration for that type - including on the later instance's own +widgets, which get_widget_theme_styles()'s memoisation can poison too. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + config_path = ( + Path(request.fspath).parent / "config" / "multi_instance_theme_test.yaml" + ) + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_earlier_instance_widget_is_themed(main_cpp: str) -> None: + assert "lv_obj_add_style(label_0, _lv_theme_style_label_main_default," in main_cpp + + +def test_later_instance_widget_is_themed(main_cpp: str) -> None: + assert "lv_obj_add_style(label_1, _lv_theme_style_label_main_default," in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index e10ca68ac64..18a7fd0913a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -42,6 +42,13 @@ script: label: text: row +esphome: + on_boot: + then: + - lvgl.theme.update: + obj: + border_width: 2 + lvgl: id: lvgl_id rotation: 90 @@ -1351,6 +1358,17 @@ lvgl: id: checkbox_id text: Checkbox align: bottom_right + - list: + id: list_a + on_add: + - lvgl.list.add: + id: list_b # fires list_b's on_add + label: {text: x} + - list: + id: list_b + on_add: + - logger.log: added + - list: id: test_list_id align: top_right diff --git a/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py new file mode 100644 index 00000000000..0842bc87076 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py @@ -0,0 +1,56 @@ +"""Regression test: action_to_code(): Confirms two +actions whose own processing each suspends mid-lambda, waiting for an ID, don't +corrupt each other's LambdaContext when interleaved. +""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.codegen as cg +from esphome.components.lvgl.automation import action_to_code +from esphome.components.lvgl.lvcode import lv_add +from esphome.core import CORE, ID +from esphome.cpp_generator import RawExpression, TemplateArguments + + +@pytest.mark.asyncio +async def test_action_to_code_survives_interleaved_suspended_contexts( + setup_core, +) -> None: + later_id_a = ID("later_var_a", False, cg.int_) + later_id_b = ID("later_var_b", False, cg.int_) + action_type = ACTION_REGISTRY["lvgl.list.add"].type_id + + async def action_a(_widget) -> None: + value = await cg.get_variable(later_id_a) + lv_add(RawExpression(f"action_a_marker({value})")) + + async def action_b(_widget) -> None: + value = await cg.get_variable(later_id_b) + lv_add(RawExpression(f"action_b_marker({value})")) + + async def run_action_a() -> None: + action_id = ID("test_action_a", is_declaration=True, type=action_type) + await action_to_code([None], action_a, action_id, TemplateArguments(), []) + + async def run_action_b() -> None: + action_id = ID("test_action_b", is_declaration=True, type=action_type) + await action_to_code([None], action_b, action_id, TemplateArguments(), []) + + async def define_later_ids() -> None: + # Both actions are still suspended, mid-LambdaContext, when this runs - + # resolving both at once lets each resume while the other's context is + # still open, rather than one finishing before the other starts. + cg.new_variable(later_id_a, RawExpression("1")) + cg.new_variable(later_id_b, RawExpression("2")) + + CORE.add_job(run_action_a) + CORE.add_job(run_action_b) + CORE.add_job(define_later_ids) + CORE.flush_tasks() + + text = "\n".join(str(s) for s in CORE.main_statements) + assert "action_a_marker(later_var_a)" in text + assert "action_b_marker(later_var_b)" in text diff --git a/tests/unit_tests/components/lvgl/test_get_part_state_selector.py b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py new file mode 100644 index 00000000000..e7d623b7c6b --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py @@ -0,0 +1,33 @@ +"""Tests for get_part_state_selector()'s three branches.""" + +from __future__ import annotations + +from esphome.components.lvgl.defines import get_part_state_selector + + +def test_default_state_returns_bare_part() -> None: + assert str(get_part_state_selector("main", "default")) == "LV_PART_MAIN" + assert str(get_part_state_selector("knob", "default")) == "LV_PART_KNOB" + + +def test_main_part_with_non_default_state_returns_bare_state() -> None: + assert str(get_part_state_selector("main", "pressed")) == "LV_STATE_PRESSED" + + +def test_non_main_part_with_non_default_state_combines_both() -> None: + assert str(get_part_state_selector("knob", "pressed")) == ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ) + + +def test_accepts_already_prefixed_part_and_state() -> None: + assert ( + str(get_part_state_selector("LV_PART_MAIN", "LV_STATE_DEFAULT")) + == "LV_PART_MAIN" + ) + assert ( + str(get_part_state_selector("LV_PART_KNOB", "LV_STATE_PRESSED")) + == "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ) diff --git a/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py new file mode 100644 index 00000000000..6caf83fe330 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py @@ -0,0 +1,129 @@ +"""Regression test: an lvgl.list.add action must fire a list's on_add trigger +even if it reaches _fire_on_add() before finish_list_triggers() has built that +list's Trigger Pvariable. + +ListType.on_create() only records on_add/on_remove configs (via +_declare_list_triggers()) - finish_list_triggers() is what actually builds the +Trigger Pvariables from them. An lvgl.list.add action for a list can be +scheduled as part of a different component's own to_code() coroutine, entirely +independent of lvgl's own, so it can reach _fire_on_add() before +finish_list_triggers() has run for that list. _fire_on_add()/_fire_on_remove() +resolve each trigger via cg.get_variable(), which blocks until +finish_list_triggers() builds it - regardless of which of the two jobs the +scheduler happens to run first. + +This is reproduced deterministically here (no reliance on incidental component +priority/scheduling) by scheduling the action's job before finish_list_triggers() +on ESPHome's own coroutine scheduler: without cg.get_variable()'s wait, the +action job would run to completion first and observe the trigger as not yet +built. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.lvcode import LvContext +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.widgets import Widget, widget_to_code +from esphome.components.lvgl.widgets.lv_list import ( + CONF_ON_ADD, + _get_list_triggers, + finish_list_triggers, + list_spec, +) +from esphome.const import CONF_AUTOMATION_ID, CONF_THEN, CONF_TRIGGER_ID, CONF_TYPE_ID +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArguments +from esphome.yaml_util import make_data_base + + +def _statements() -> list[str]: + return [str(s) for s in CORE.main_statements] + + +@pytest.mark.asyncio +async def test_list_add_action_running_before_finish_list_triggers_still_fires_on_add( + setup_core, +) -> None: + config = container_schema(list_spec)( + { + "id": "test_list", + CONF_ON_ADD: [{"lambda": make_data_base("return;")}], + } + ) + # Auto-generated IDs (trigger/automation/action) are normally resolved to + # unique names by esphome's full config pass before code generation; do + # that by hand here since this test only exercises the widget/trigger + # codegen slice in isolation. + automation_conf = config[CONF_ON_ADD][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + parent = MockObj("parent_obj") + async with LvContext(): + await widget_to_code(config, list_spec, parent) + + # Schedule the lvgl.list.add action's job before finish_list_triggers()'s - + # mirroring an action that lives in a different component's automation than + # lvgl's own to_code(), which can reach this action before lvgl gets to build + # this list's on_add/on_remove triggers. + entry = ACTION_REGISTRY["lvgl.list.add"] + add_config = entry.schema({"id": "test_list", "label": {"text": "row"}}) + action_id = ID("test_list_add_action", is_declaration=True, type=entry.type_id) + + async def run_add_action() -> None: + async with LvContext(): + await entry.coroutine_fun(add_config, action_id, TemplateArguments(), []) + + CORE.add_job(run_add_action) + CORE.add_job(finish_list_triggers) + CORE.flush_tasks() + + statements = _statements() + assert any("->trigger(" in s for s in statements), ( + "on_add did not fire: the lvgl.list.add action ran before " + "finish_list_triggers() built the list's on_add trigger, and " + "_fire_on_add() didn't wait for it" + ) + + +@pytest.mark.asyncio +async def test_on_add_recorded_before_widget_registered(setup_core) -> None: + """Widget.create() is what makes a list visible to get_widgets(), so an + action interleaved with its creation could resolve get_widgets() and reach + _fire_on_add() right after Widget.create() runs. Its on_add config must + already be recorded by then - ListType.on_create() (called before + Widget.create()) is what guarantees that, not ListType.to_code() (called + after). + """ + config = container_schema(list_spec)( + { + "id": "test_list", + CONF_ON_ADD: [{"lambda": make_data_base("return;")}], + } + ) + automation_conf = config[CONF_ON_ADD][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + seen_on_add_counts = [] + real_create = Widget.create + + def spy_create(name, var, wtype, config=None): + seen_on_add_counts.append(len(_get_list_triggers(name).on_add)) + return real_create(name, var, wtype, config) + + parent = MockObj("parent_obj") + with patch.object(Widget, "create", side_effect=spy_create): + async with LvContext(): + await widget_to_code(config, list_spec, parent) + + assert seen_on_add_counts == [1], ( + "on_add wasn't recorded yet when Widget.create() registered the list" + ) diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py index 390f67dffcf..12ebd3a12f1 100644 --- a/tests/unit_tests/components/lvgl/test_table_codegen.py +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest from esphome.automation import ACTION_REGISTRY -from esphome.components.lvgl.defines import set_widgets_completed from esphome.components.lvgl.lvcode import LvContext from esphome.components.lvgl.schemas import container_schema from esphome.components.lvgl.trigger import generate_triggers @@ -151,7 +150,6 @@ async def test_selected_cell_omitted_entirely_when_not_configured( @pytest.mark.asyncio async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None: await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]}) - set_widgets_completed(True) # Only inspect statements emitted by the action below, not by creation. before = len(_statements()) @@ -194,7 +192,6 @@ async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> parent = MockObj("parent_obj") async with LvContext(): await widget_to_code(config, table_spec, parent) - set_widgets_completed(True) await generate_triggers() statements = _statements() diff --git a/tests/unit_tests/components/lvgl/test_theme_update_ordering.py b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py new file mode 100644 index 00000000000..62b8a4eaedf --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py @@ -0,0 +1,44 @@ +"""Regression test: an lvgl.theme.update action must still apply its style +change even if it runs before theme_to_code() has built that style. + +theme_update_to_code() reads get_theme_widget_map() synchronously and raises +cv.Invalid if the requested style isn't there yet - relying on theme_to_code() +(which materialises a style for every requested widget/part/state combo) to +have always already run. That's guaranteed when the action lives inside the +lvgl: block's own automations (same to_code() job, sequential), but not when +it's scheduled as part of a different component's own to_code() job - e.g. +tests/components/lvgl/lvgl-package.yaml's `esphome: on_boot:` case, which this +test reproduces at the scheduler level. +""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.schemas import theme_update_schema +from esphome.components.lvgl.styles import theme_to_code +from esphome.core import CORE, ID +from esphome.cpp_generator import TemplateArguments + + +@pytest.mark.asyncio +async def test_theme_update_before_theme_to_code_still_applies(setup_core) -> None: + add_config = theme_update_schema({"obj": {"border_width": 2}}) + + entry = ACTION_REGISTRY["lvgl.theme.update"] + action_id = ID("test_theme_update_action", is_declaration=True, type=entry.type_id) + + async def run_update_action() -> None: + await entry.coroutine_fun(add_config, action_id, TemplateArguments(), []) + + # Scheduled before theme_to_code()'s job - mirrors the action being reached + # from a different component's own to_code() job than lvgl's. + CORE.add_job(run_update_action) + CORE.add_job(theme_to_code, {}) + CORE.flush_tasks() + + statements = [str(s) for s in CORE.main_statements] + assert any("style_set_border_width" in s for s in statements), ( + "theme.update's border_width change was never applied" + ) From 3cccdca7718fbcf4906ece00ecede1d1ba3c9539 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:55:50 +0200 Subject: [PATCH 745/759] [modbus] Fix a format warning when logging the frame buffer size (#19934) --- esphome/components/modbus/modbus.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 0cf57ef7aa5..5f237be8dd7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -210,7 +210,7 @@ void ModbusServerHub::parse_modbus_frames() { this->deferred_payload_len_ = 0; } size_t size = this->rx_buffer_.size(); - ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + ESP_LOGVV(TAG, "Parsing frames buffer size = %zu", size); bool retry_as_client = false; // A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex). const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS; From f327cbbcfe429fdbb69adf1734626198095df69b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:56:15 +0200 Subject: [PATCH 746/759] [dlms_meter] Fix a format warning when logging the receive timeout (#19935) --- esphome/components/dlms_meter/dlms_meter.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index bdbf798df52..e672fb200fc 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -86,7 +86,7 @@ void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:"); - ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_); ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); for (const auto &pattern : this->custom_patterns_) { From e7f2159f0e80da1d73b9b776b813399fbc3ef875 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:56:37 +0200 Subject: [PATCH 747/759] [api] Fix format warnings in the api test config (#19936) --- tests/components/api/common-base.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 5e3139da48c..f55e7b176dd 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -92,7 +92,7 @@ api: then: - logger.log: # yamllint disable rule:line-length - format: "Bool: %s (%u), Int: %ld (%u), Float: %f (%u), String: %s (%u)" + format: "Bool: %s (%zu), Int: %ld (%zu), Float: %f (%zu), String: %s (%zu)" # yamllint enable rule:line-length args: - YESNO(bool_arr[0]) @@ -116,7 +116,7 @@ api: then: - delay: 20ms - logger.log: - format: "Delayed: %s (%u ints, %u strings)" + format: "Delayed: %s (%zu ints, %zu strings)" args: - name.c_str() - int_arr.size() @@ -139,7 +139,7 @@ api: message: !lambda 'return name;' on_success: - logger.log: - format: "Notified %s (%u ints)" + format: "Notified %s (%zu ints)" args: - name.c_str() - int_arr.size() From 987618b49e753d88796d5db082339d7985d86f02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:57:01 +0200 Subject: [PATCH 748/759] [micro_wake_word] Fix a format warning when logging a model version mismatch (#19937) --- esphome/components/micro_wake_word/model_data.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp index a7326ab77a0..c235e290ec2 100644 --- a/esphome/components/micro_wake_word/model_data.cpp +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -87,7 +87,8 @@ bool ModelData::validate_and_mark_ready() { const tflite::Model *model = tflite::GetModel(this->data_); if (model->version() != TFLITE_SCHEMA_VERSION) { - ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version()); + ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %" PRIu32 ")", TFLITE_SCHEMA_VERSION, + model->version()); return false; } From 39f41656b9578dcc269d35a1a7938f4b02c20239 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:57:46 +0200 Subject: [PATCH 749/759] [seeed_mr60bha2] Use the sub_* helpers for optional child entities (#19939) --- .../seeed_mr60bha2/binary_sensor.py | 7 +++---- esphome/components/seeed_mr60bha2/sensor.py | 19 ++++++------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/esphome/components/seeed_mr60bha2/binary_sensor.py b/esphome/components/seeed_mr60bha2/binary_sensor.py index 4130bac224a..b009de8381a 100644 --- a/esphome/components/seeed_mr60bha2/binary_sensor.py +++ b/esphome/components/seeed_mr60bha2/binary_sensor.py @@ -17,8 +17,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60bha2_component = await cg.get_variable(config[CONF_MR60BHA2_ID]) + hub = await cg.get_variable(config[CONF_MR60BHA2_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(mr60bha2_component.set_has_target_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/seeed_mr60bha2/sensor.py b/esphome/components/seeed_mr60bha2/sensor.py index a2f41a90a81..d815aa1a8c3 100644 --- a/esphome/components/seeed_mr60bha2/sensor.py +++ b/esphome/components/seeed_mr60bha2/sensor.py @@ -51,16 +51,9 @@ CONFIG_SCHEMA = cv.Schema( 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) - cg.add(mr60bha2_component.set_breath_rate_sensor(sens)) - if heart_rate_config := config.get(CONF_HEART_RATE): - sens = await sensor.new_sensor(heart_rate_config) - cg.add(mr60bha2_component.set_heart_rate_sensor(sens)) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(mr60bha2_component.set_distance_sensor(sens)) - if num_targets_config := config.get(CONF_NUM_TARGETS): - sens = await sensor.new_sensor(num_targets_config) - cg.add(mr60bha2_component.set_num_targets_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR60BHA2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BREATH_RATE, hub.set_breath_rate_sensor) + await sensors(CONF_HEART_RATE, hub.set_heart_rate_sensor) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_NUM_TARGETS, hub.set_num_targets_sensor) From 3062e1f844c61c376376ad2fa5b6c43085e969bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:58:02 +0200 Subject: [PATCH 750/759] [smt100] Use the sub_* helpers for optional child entities (#19940) --- esphome/components/smt100/sensor.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index 7ba7da801cd..dae98bc1897 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -83,22 +83,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_COUNTS in config: - sens = await sensor.new_sensor(config[CONF_COUNTS]) - cg.add(var.set_counts_sensor(sens)) - - if CONF_PERMITTIVITY in config: - sens = await sensor.new_sensor(config[CONF_PERMITTIVITY]) - cg.add(var.set_permittivity_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture_sensor(sens)) - - if CONF_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_VOLTAGE]) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_COUNTS, var.set_counts_sensor) + await sensors(CONF_PERMITTIVITY, var.set_permittivity_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_MOISTURE, var.set_moisture_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) From 08ed25833d34f05ea1d097331817ca6864d74be8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:58:14 +0200 Subject: [PATCH 751/759] [mmc5603] Use the sub_* helpers for optional child entities (#19941) --- esphome/components/mmc5603/sensor.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index a9f240508c1..70c70877b75 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -82,17 +82,10 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_datarate(auto_data_rate(config))) - if CONF_FIELD_STRENGTH_X in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X]) - cg.add(var.set_x_sensor(sens)) - if CONF_FIELD_STRENGTH_Y in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y]) - cg.add(var.set_y_sensor(sens)) - if CONF_FIELD_STRENGTH_Z in config: - sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z]) - cg.add(var.set_z_sensor(sens)) - if CONF_HEADING in config: - sens = await sensor.new_sensor(config[CONF_HEADING]) - cg.add(var.set_heading_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) + await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor) + await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) + await sensors(CONF_HEADING, var.set_heading_sensor) if CONF_AUTO_SET_RESET in config: cg.add(var.set_auto_set_reset(config[CONF_AUTO_SET_RESET])) From a706fa9e6b72a2f2bf1a07203b806a686c11fd28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:58:30 +0200 Subject: [PATCH 752/759] [gdk101] Use the sub_* helpers for optional child entities (#19942) --- esphome/components/gdk101/sensor.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 4ed081a7bef..4fb65d9c256 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -63,18 +63,8 @@ CONFIG_SCHEMA = cv.Schema( 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): - sens = await sensor.new_sensor(radiation_dose_per_1m) - cg.add(hub.set_rad_1m_sensor(sens)) - - if radiation_dose_per_10m := config.get(CONF_RADIATION_DOSE_PER_10M): - sens = await sensor.new_sensor(radiation_dose_per_10m) - cg.add(hub.set_rad_10m_sensor(sens)) - - if status_config := config.get(CONF_STATUS): - sens = await sensor.new_sensor(status_config) - cg.add(hub.set_status_sensor(sens)) - - if measurement_duration_config := config.get(CONF_MEASUREMENT_DURATION): - sens = await sensor.new_sensor(measurement_duration_config) - cg.add(hub.set_measurement_duration_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADIATION_DOSE_PER_1M, hub.set_rad_1m_sensor) + await sensors(CONF_RADIATION_DOSE_PER_10M, hub.set_rad_10m_sensor) + await sensors(CONF_STATUS, hub.set_status_sensor) + await sensors(CONF_MEASUREMENT_DURATION, hub.set_measurement_duration_sensor) From c2d830ebd3e3afc23a2d4978608e45433fbdbb78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:58:45 +0200 Subject: [PATCH 753/759] [pmwcs3] Use the sub_* helpers for optional child entities (#19943) --- esphome/components/pmwcs3/sensor.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 19d0083fd04..61f89afa41b 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -67,21 +67,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_E25 in config: - sens = await sensor.new_sensor(config[CONF_E25]) - cg.add(var.set_e25_sensor(sens)) - - if CONF_EC in config: - sens = await sensor.new_sensor(config[CONF_EC]) - cg.add(var.set_ec_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_VWC in config: - sens = await sensor.new_sensor(config[CONF_VWC]) - cg.add(var.set_vwc_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_E25, var.set_e25_sensor) + await sensors(CONF_EC, var.set_ec_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_VWC, var.set_vwc_sensor) # Actions From 6121bb3bcfe676dfe830aeca55ed35bc64bfbbac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 10:59:23 +0200 Subject: [PATCH 754/759] [ufm01] Fix a format warning when logging the frame sync position (#19944) --- esphome/components/ufm01/ufm01.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 880132bad39..fbacd84db0f 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -261,7 +261,7 @@ bool UFM01Component::process_active_stream_() { } if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { - ESP_LOGD(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + ESP_LOGD(TAG, "not start of data at %" PRId32 " (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); this->read_index_ = 0; continue; } From 199cc08270a1e636d1557d875829ae76f3215138 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 11:00:04 +0200 Subject: [PATCH 755/759] [midea] Fix a format warning in the esp32 test config (#19946) --- tests/components/midea/test.esp32-idf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/midea/test.esp32-idf.yaml b/tests/components/midea/test.esp32-idf.yaml index 5ad22b5b93c..edaa35691be 100644 --- a/tests/components/midea/test.esp32-idf.yaml +++ b/tests/components/midea/test.esp32-idf.yaml @@ -12,4 +12,4 @@ wifi: interval: - interval: 10s then: - - lambda: ESP_LOGD("test", "%u", millis()); + - lambda: ESP_LOGD("test", "%" PRIu32, millis()); From 920880470730b455b090d62024fcaf828675e218 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 11:10:20 +0200 Subject: [PATCH 756/759] [json] Fix format truncation warnings in the arena tests (#19938) --- tests/components/json/test_json_arena.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/json/test_json_arena.cpp b/tests/components/json/test_json_arena.cpp index 8d1d44d1bb0..1d42997225e 100644 --- a/tests/components/json/test_json_arena.cpp +++ b/tests/components/json/test_json_arena.cpp @@ -181,7 +181,7 @@ TEST(JsonArena, StateDocumentsFitWithoutTouchingTheFallback) { root["value"] = static_cast(value_buf); root["state"] = static_cast(value_buf); JsonArray options = root["option"].to(); - char option_bufs[40][34]; + char option_bufs[40][44]; // room for any int, so -Wformat-truncation stays quiet for (int i = 0; i < 40; i++) { snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); options.add(JsonString(option_bufs[i], true)); @@ -199,7 +199,7 @@ TEST(JsonArena, StateDocumentsFitWithoutTouchingTheFallback) { { JsonBuilder builder(&copied_arena); JsonArray options = builder.root()["option"].to(); - char option_bufs[40][34]; + char option_bufs[40][44]; // room for any int, so -Wformat-truncation stays quiet for (int i = 0; i < 40; i++) { snprintf(option_bufs[i], sizeof(option_bufs[i]), "option number %02d padded to twenty", i); options.add(static_cast(option_bufs[i])); @@ -219,7 +219,7 @@ TEST(JsonArena, DocumentMatchesTheHeapAllocator) { arr.add(i); } JsonArray strings = builder.root()["s"].to(); - char buf[32]; + char buf[40]; // room for any int, so -Wformat-truncation stays quiet for (int i = 0; i < 60; i++) { snprintf(buf, sizeof(buf), "string number %04d padded", i); strings.add(buf); From e8c5cd56a8265037c7f6438796727b1addb09119 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 11:28:46 +0200 Subject: [PATCH 757/759] [xdb401] Fix format warnings in the timing and raw pressure logs (#19945) --- esphome/components/xdb401/xdb401.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 3a24d637609..6deab23828d 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -74,13 +74,13 @@ void XDB401Component::check_measurement_ready_(uint8_t attempt) { // Bit 3 shall be 0 when measurement is ready if ((meas_resp[0] & MASK_MEASURE_READY) == 0) { - ESP_LOGV(TAG, "Meas mode entered after %u ms", attempt * CHECK_DELAY); + ESP_LOGV(TAG, "Meas mode entered after %" PRIu32 " ms", attempt * CHECK_DELAY); this->read_measurement_(); return; } if (attempt >= CHECK_ATTEMPTS) { - ESP_LOGE(TAG, "Device not in measurement mode after timeout of %u ms", CHECK_DELAY * CHECK_ATTEMPTS); + ESP_LOGE(TAG, "Device not in measurement mode after timeout of %" PRIu32 " ms", CHECK_DELAY * CHECK_ATTEMPTS); this->handle_comm_failure_("Measurement timeout"); return; } @@ -129,7 +129,7 @@ i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { // Sign-extend 24-bit big-endian pressure value to int32_t. int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; - ESP_LOGD(TAG, "Pressure data raw %i", raw_pressure); + ESP_LOGD(TAG, "Pressure data raw %" PRId32, raw_pressure); pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * XDB401Component::full_scale_pressure_pa(this->pressure_range_bar_); From 6c432473958fd2e7612e3c8f83e30465b3564f65 Mon Sep 17 00:00:00 2001 From: Panda <735591750@QQ.COM> Date: Wed, 30 Sep 2026 18:41:52 +0800 Subject: [PATCH 758/759] [core] Ignore empty CLI address cache entries (#19924) --- esphome/address_cache.py | 11 ++++++-- tests/unit_tests/test_address_cache.py | 36 ++++++++++++++++++++++++++ tests/unit_tests/test_helpers.py | 20 ++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/esphome/address_cache.py b/esphome/address_cache.py index 4fb3689818b..8d7107b782d 100644 --- a/esphome/address_cache.py +++ b/esphome/address_cache.py @@ -148,6 +148,13 @@ class AddressCache: continue hostname, ips = arg.split("=", 1) # Normalize hostname for consistent lookups - normalized = normalize_hostname(hostname) - cache[normalized] = [ip.strip() for ip in ips.split(",")] + normalized = normalize_hostname(hostname.strip()) + addresses = [ip for value in ips.split(",") if (ip := value.strip())] + if not normalized or not addresses: + _LOGGER.warning( + "Invalid cache entry: %s (hostname and at least one address are required)", + arg, + ) + continue + cache[normalized] = addresses return cache diff --git a/tests/unit_tests/test_address_cache.py b/tests/unit_tests/test_address_cache.py index 1ca28c4f029..71fb632d6df 100644 --- a/tests/unit_tests/test_address_cache.py +++ b/tests/unit_tests/test_address_cache.py @@ -257,6 +257,42 @@ def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None: assert "Invalid cache format: also_invalid" in caplog.text +@pytest.mark.parametrize( + "args,expected", + [ + (["device.local="], {}), + (["device.local= , , "], {}), + (["=192.168.1.10"], {}), + (["...=192.168.1.10"], {}), + ( + [" device.local. = , 192.168.1.10, , fe80::1, "], + {"device.local": ["192.168.1.10", "fe80::1"]}, + ), + ], +) +def test_from_cli_args_discards_empty_addresses_and_hostnames( + args: list[str], expected: dict[str, list[str]], caplog: LogCaptureFixture +) -> None: + """Only usable cache entries may prevent normal hostname resolution.""" + cache = AddressCache.from_cli_args(args, args) + + assert cache.mdns_cache == expected + assert cache.dns_cache == expected + assert cache.has_cache() == bool(expected) + if not expected: + assert cache.get_addresses("device.local") is None + assert "Invalid cache entry" in caplog.text + + +def test_invalid_cache_entry_does_not_replace_valid_entry() -> None: + """A later empty entry must not remove an earlier usable address.""" + args = ["device.local=192.168.1.10", "device.local=", "server.com=10.0.0.1"] + cache = AddressCache.from_cli_args(args, args) + + assert cache.get_mdns_addresses("DEVICE.LOCAL.") == ["192.168.1.10"] + assert cache.get_dns_addresses("server.com") == ["10.0.0.1"] + + def test_from_cli_args_ipv6() -> None: """Test handling of IPv6 addresses.""" mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"] diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index ff82fa3c80f..5bdecf2fd3a 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1129,6 +1129,26 @@ def test_resolve_ip_address_cache_miss() -> None: assert result[0][4][0] == "192.168.1.100" +@pytest.mark.parametrize("hostname", ["test.local", "example.com"]) +def test_resolve_ip_address_empty_cache_entry_falls_back(hostname: str) -> None: + """An empty CLI cache entry must use normal DNS or mDNS resolution.""" + cache = AddressCache.from_cli_args([f"{hostname}="], [f"{hostname}="]) + mock_addr_info = AddrInfo( + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053), + ) + + with patch("esphome.resolver.AsyncResolver") as MockResolver: + MockResolver.return_value.resolve.return_value = [mock_addr_info] + + result = helpers.resolve_ip_address(hostname, 6053, address_cache=cache) + + MockResolver.assert_called_once_with([hostname], 6053) + assert result[0][4][0] == "192.168.1.100" + + def test_resolve_ip_address_mixed_cached_uncached() -> None: """Test resolution with mix of cached and uncached hosts.""" cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]}) From 3fac97867ec2aeca4b24f2fb0663614c7a36d31d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Sep 2026 13:17:07 +0200 Subject: [PATCH 759/759] [espidf] Resolve cmake through the IDF environment when reading the CMake cache (#19947) --- esphome/espidf/toolchain.py | 4 +++- tests/unit_tests/test_espidf_toolchain.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index d1aeae96031..4243eff0ba2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -138,7 +138,9 @@ def _get_cmake_output(build_dir) -> str: if not (Path(build_dir) / "CMakeCache.txt").is_file(): raise EsphomeError(f"No ESP-IDF build found in {build_dir}") - cmd = ["cmake", "-LA", "-N", "."] + # Resolve to an absolute path: Windows locates a child process + # through the parent's PATH, not the env passed to it. + cmd = [_get_idf_tool("cmake"), "-LA", "-N", "."] env = _get_idf_env() result = subprocess.run( diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index ee21cd77a9c..c012785e892 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -264,6 +264,7 @@ def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: ) with ( patch.object(toolchain, "_get_idf_env", return_value={}), + patch.object(toolchain, "_get_idf_tool", return_value="/idf/tools/cmake"), patch.object(toolchain.subprocess, "run", return_value=completed) as mock_run, ): assert toolchain._get_cmake_output(build_dir) == completed.stdout @@ -271,6 +272,9 @@ def test_get_cmake_output_with_configured_build(setup_core: Path) -> None: assert toolchain._get_cmake_output(build_dir) == completed.stdout mock_run.assert_called_once() + # The resolved path, never a bare "cmake": Windows locates the child + # through the parent's PATH, where the IDF-managed cmake is missing. + assert mock_run.call_args.args[0][0] == "/idf/tools/cmake" assert toolchain._get_cmake_tool_path("CMAKE_ADDR2LINE") == Path("/tool/addr2line")